Skip to content

feat: host-driven runs, leases and adapters for v1.10.0 - #63

Merged
juicycleff merged 59 commits into
mainfrom
feat/ai-agent-runtime
Oct 30, 2026
Merged

juicycleff merged 59 commits into
mainfrom
feat/ai-agent-runtime

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

This is the v1.10.0 release. After it merges, you cut the tags with:

gh workflow run release.yml --ref main -f tag=v1.10.0

That tags the root module and api, sentinel, extension, integrations/fabriq, integrations/sandbox, optimization and the new mcpbridge at <dir>/v1.10.0. Everything is pinned to released tags: forge v1.13.2, grove v1.10.1, nexus v1.9.1, shield v1.9.1 and weave v1.9.2.

What you get

Hosts can now drive runs themselves. A paused run is decided with DecideCheckpoint or answered with ProvideResults, it moves to ready, and your host continues it with ContinueRun from whichever replica its job runner picks. Turn on leasing with WithRunLease and a run whose worker dies becomes reclaimable, and ContinueRun picks it up from its last saved step. A tool call that was already handed to a tool that isn't retry-safe is never sent twice: the run ends incomplete with ErrEffectUncertain. Leasing is opt-in, so a host that upgrades and wires nothing keeps the v1.9.0 behaviour, Stop included.

Resumed runs carry the principal that started them (WithPrincipalCodec), every suspension has its own deadline, and RunSuspended fires on every pause.

Tools can be typed now. tool.New builds the JSON Schema from a Go struct, and RetrySafe marks the ones that are safe to run twice. The new mcpbridge module registers an MCP server's tools as cortex tools, with read-only ones marked retry-safe.

The adapters do the real work now:

  • nexus carries the tenant, run and agent metadata, the model's native content (thinking signatures survive a pause or a reclaim) and tool choice
  • weave searches only the run's tenant, plus shared collections you name
  • shield actually evaluates now, scans tool calls under the run's tenant, fails closed when it can't, and can stream model output in segments it has already checked

The REST module takes an Authorizer, which guards every route and decides who approved a checkpoint, and it serves the tool catalog. It also writes each response body once. Before this, most routes wrote two JSON objects into one body, which every normal JSON client rejects.

Upgrading

Read MIGRATION.md, "Upgrading from v1.9.0". The short version: migrations 20261101000001 to 20261101000004 are additive, the nexus adapter refuses calls without a tenant, shield now gates publication even when you pass your own WithSafety, and weave uses the first scope level as its tenant. If you upgrade straight from v1.8.0, its section covers pauses written at schema 1.

The CHANGELOG's [1.10.0] section lists the known limits, so they don't surprise you later. A turn whose text publication redacted loses its thinking blocks on replay, for one.

How it was checked

Every task went through its own review, and the whole branch had a final review before this PR. These ran on the branch:

  • make fmt, make lint (0 issues in all 11 modules) and goimports
  • the engine suite under -race (about 20 minutes)
  • store conformance on SQLite, Postgres and Mongo with -v: 2566 passed, none skipped
  • every submodule tidy and under -race, plus make test-integration
  • the docs site: types, lint and build

A few timing tests flaked once each while the machine was heavily loaded and passed on every rerun, so if CI trips on A2ARecipientRecoveryReclaimsReply in Mongo, rerun it before you dig in.

Shield's segment checking was attacked directly before it shipped. Private keys streamed a few bytes at a time (RSA PKCS#1, PKCS#8 and EC, 30 fresh keys each), password: value and api_key=value lines, and forged quote wrappers in retrieved text all came out the same as a whole-message check.

Postgres Migrate now holds a cortex advisory lock for the whole call, so
replicas that boot together take turns. Before, all but one failed with
"migration lock is held by another process". The key is
-918477416930547652, the FNV-1a 64-bit hash of the Migrate symbol, so it
never meets grove's key 1. Migrate takes and releases it on one dedicated
connection, and it covers grove's tracking-table creation, the migration
group, the rescope and the session backfill. The group runs through
grove's pgmigrate executor now, which retries key 1 for up to five
minutes, and the old fail-fast executor is gone.

A waiting replica polls pg_try_advisory_lock. We tried blocking in
pg_advisory_lock first and eight replicas hung: each blocked call keeps a
snapshot open, and the holder's CREATE INDEX CONCURRENTLY waits for every
older snapshot.

Other grove users on the database never take the cortex lock. On a fresh
database one of them can win the CREATE of grove_migrations and leave
cortex with 42P07 or 42710, so Migrate retries that up to five times.

Tests cover eight replicas on a fresh database (run 12 times), cortex
alongside plain grove migrators of another group, a held cortex lock, a
held key 1 and a cancelled wait. The Postgres store page explains the
lock and what a host running the group in its own pass has to do.
The replica tests passed with the cortex lock deleted, because grove's key
1 already serializes the migration group. What the lock really protects
is the rescope and the default-session backfill, which run after grove
lets go of key 1. A new test seeds pre-v1.8.0 agents and conversation
rows and starts eight replicas with one Rescoper that takes 100ms a call.
It checks that each legacy identifier is rescoped once, nothing is left
unscoped, and each agent ends up with one default session holding all
of its rows. With withMigrateLock reduced to fn(ctx) it failed 5 runs
out of 5, every replica but the first hitting a rescope collision.

Migrate also fails fast on a pool of one now. Grove's driver doesn't
report the pool size, so it waits up to five seconds for a second
connection and then says it needs two, where before it hung until the
context ended. The unlock gets its own five-second timeout, tests cover
the lock coming back when the work errors or panics, and the docs name
driver.WithPoolSize as what sizes the pool.
…n config

engine.WithRunConcurrency(n) caps how many runs execute at once in a
process. Zero, the default, is no limit. A run that finds every slot taken
waits for one only as long as its ctx allows, then returns an error that
matches ctx.Err() and writes no run row. RunAgent, StreamAgent,
RunPrepared, StreamPrepared, Resume, ResumeStream, ResolveCheckpoint, the
message bus's resumes and RecoverAgentReply are all counted. Streams hold
their slot until the channel closes. A run started on the ctx of a run that
already holds a slot shares it, so a tool that calls RunAgent can't
deadlock on a cap of 1. Task worker runs and prepared orchestrations stay
outside the cap.

Stop now drains inside the lifecycle lock before it touches task services.
New runs get cortex.ErrEngineStopping and Stop waits for the ones in
flight. If its ctx ends first, Stop signals task services and returns the
deadline error without stopping the sweeper or the bus and without
OnShutdown; the runs keep executing and record their own result, and a
second Stop finishes the job. Start admits runs again.

The Forge extension now applies its config: the default_* keys and
shutdown_timeout become the engine's cortex.Config, run_concurrency goes
through WithRunConcurrency, and Stop is bounded by shutdown_timeout. The
option order is now the precedence rule. The discovered store and the
nexus, shield, weave and sentinel adapters go in before your own options,
so WithStore and WithEngineOption win over them. WithoutAutoAdapters skips
the discovery. Both DefaultConfig() values of RunConcurrency drop from 4
to 0.

Verified with GOWORK=off go test -race on engine and extension (the new
admission and config tests ran five times clean), the root suite, scopespy
and every sub-module, and golangci-lint on root and extension.
Before this change, a peer reply that landed during Stop's drain was lost.
The bus claimed or sealed the waiting ask and then called the resume,
which admission refused, so the asker sat paused behind a consumed claim
until its suspension expired. A directive that arrived during the drain
was worse: the asker got "could not answer: cortex: engine is stopping"
as a Failure reply and read it as its peer's answer.

The bus now has an Admitter, and the engine gives it run admission. Drain
asks before it claims each delivery, and the ask paths ask before they
claim or seal, so while the engine stops nothing new is claimed. Queued
rows wait for another replica's dispatcher, or for this one after Start.
A reply sent from outside a run during the drain is queued as a delivery
and leaves the ask alone. The expired-ask sweep ends its pass quietly. Work
the bus admitted before the drain keeps its ticket, so the reply resume
after a consumed claim runs through stopping and Stop waits for it. If a
run is still refused after its delivery was claimed, the row goes back to
queued with no reply, and nobody gets a Failure for ErrEngineStopping.

Background agent-reply recovery treats ErrEngineStopping as a retry for
a later pass and logs it at debug.

Review minors fixed here: a nested run can no longer join a parent that
is releasing (the released flag is guarded by the admission lock);
Config() and UpdateConfig() report the cap the engine applies; WithStore
stops the extension from looking for a default grove database; the docs
and comments list plan replanning among the runs the cap doesn't count.

Verified with GOWORK=off go test -race on engine and a2a (the drain tests
three times over), the root suite, scopespy, every sub-module and
make lint.
…pause

An authorizer can now return cortex.ApprovalRequired{Reason, TTL}. It still
matches ErrRequiresApproval, so existing hosts keep working, and the engine
puts the reason on the pending call (PendingCall.Note) and on the checkpoint.
External tools take engine.ExternalTTL, and a run waiting on another agent
follows its ask's reply-by plus WithAgentReplyGrace (5 minutes), so the bus
times the ask out before the engine fails the run. The suspension expires at
the earliest deadline among its calls. A negative approval TTL denies the call.

WithSuspensionTTL(0) now means no default deadline. The sweeper runs whenever
there is a store, because a call can carry a deadline of its own.

plugin.RunSuspended fires once per pause, for every reason, after the flip to
paused and before the step's questions go out to peers.

Verified with GOWORK=off: go test -race for the root, a2a, suspension, engine
(912s), store/sqlite, api and extension; make fmt and make lint report 0
issues in every module.
A paused run used to resume under whoever resumed it, so after an
approval the authorizer was asked about the approver. The engine now
encodes the caller's principal with a PrincipalCodec when it creates a
run, stores the bytes on cortex_runs.principal, and decodes them before
every claim: Resume, ResumeStream, ResolveCheckpoint, the messaging bus
and RecoverAgentReply. A revoked principal fails the run. Any other
decode error returns before the claim and leaves the run paused.

You need a codec once you install a tool authorizer: engine.New returns
ErrPrincipalCodecRequired otherwise. ResolveCheckpoint records the
decider in Decision.DecidedByPrincipal.

Migration 20261101000001 adds the column on Postgres and SQLite. Mongo
needs no DDL.

Verified with -race on root, engine, store/sqlite, store/postgres and
store/mongo (containers, no skips), plus api, extension and sentinel.
make lint reports 0 issues in every module.
… responses

Follow-ups to the principal work in 76b9fb4.

JSONPrincipalCodec now reports stored bytes that can't decode into T
(corrupt, or written before T changed) as ErrPrincipalRevoked, since no
retry fixes them. Other decode errors stay transient. restorePrincipal
returns the revoked and transient cases separately, so a claim site
returns err before the claim and fails the run with revoked after it.
The engine logs a warning with the run ID when it does that, and a run
under required verification now keeps ErrPrincipalRevoked as its error
in place of ErrVerificationUnavailable.

Decision.DecidedByPrincipal stays in the stored decision JSON. The REST
checkpoint list and the dashboard contract serve checkpoints through
Checkpoint.Public, which drops it.

Task-run children created through coordination still carry no
principal bytes and resume with a nil principal. A test pins that, and
the authorization docs say so and say what to key on.

New tests cover a second pause on the same run, the revoked reason on
run.Error, and a transient decode during ResolveCheckpoint. The two
fixtures that apply WithToolAuthorizer straight to an engine now get a
codec too.
You can now answer a paused run from an HTTP handler and finish it later.
DecideCheckpoint and ProvideResults record the answer, move the run to
ready and return without calling the model. ContinueRun claims a ready
run from a job on any replica and runs it as the principal that started
it.

ResolveCheckpoint is DecideCheckpoint followed by ContinueRun, so the
decision is recorded before the run moves. WithRejectionsReturnToModel
hands a rejection back to the model as a denied tool result. CancelRun
takes ready and reclaimable runs, and a run failed for a revoked
principal closes its pending checkpoint.
A decision now answers only the pause its checkpoint was opened for.
DecideCheckpoint closes a checkpoint the run has moved past as
superseded and refuses it, and MarkReady refuses readiness that names
another suspension, so a stale checkpoint can't approve the run's next
gated call.

Checkpoint Resolve writes only on a pending row in every backend. The
losing decider gets ErrInvalidState and fires no hook, and a late
approval can't overwrite a cancel. Readiness keeps the decider, and
ContinueRun records a decision whose checkpoint write was lost. A
suspension read that fails before the claim leaves the run ready.
A decision on a run with no current suspension is refused with
ErrInvalidState and closes nothing, because another decider may still
be recording. Only the ready path closes a checkpoint as superseded,
and only when the run waits on a different pause. Every losing decider
now gets ErrInvalidState. A decider whose own decision a job already
recorded treats that as success, and fires RunReady only while the run
is still ready.
api.WithAuthorizer puts all 91 routes behind Authorize, keyed by each
route's operation id and whether it writes. A refusal is a 403 unless it
wraps a cortex not-found or scope error. With an authorizer,
resolveCheckpoint records DecidedBy for the request principal and
ignores the body's decided_by. Without one the module is for
single-trust deployments only, and the docs now say so.

GET /v1/tools lists engine.Tools with kind and retry_safe, and
/v1/tools/:name/schema serves the tool's parameters. Checkpoint
responses use their own type, so decided_by_principal is gone from the
OpenAPI schema too.
The prepared-team recovery scan returned ErrRunConflict when its start
of an admitted reply lost to the sweep marking the same run reclaimable.
That ended the pass, and the coordinator then failed the whole team. The
scan now skips that child like one already started and goes on with the
rest; the next pass, or ContinueRun, takes it from there.

The new test kills an admitter between its admission and its start in a
child process, then has the scan's first start lose once. Also adds the
agent_reply counterpart of the lost expiry reclaim test.
StartResumeLeased from another scope now has to be
ErrResumeAdmissionNotFound and AdmitResumeLeased ErrInvalidScope, the
same refusals plain StartResume and AdmitResume give there.
48 handlers wrote their response with ctx.JSON and also returned it, so
forge serialized it a second time. Clients got two concatenated JSON
objects and the server logged a superfluous WriteHeader. A 200 is now
returned for forge to write; a 201, and the 410 of a replay gap, is
written by the handler, which returns nil.

A new test drives a success path in every handler file and fails on a
second header or a second JSON value; the replay test checks its 200
and 410 bodies the same way.
Authorize gets the operation id and the request context only, so a
per-resource rule belongs in the host's own lookups. sendAgentMessage's
from stays caller-asserted with an authorizer; it's attribution, and
the reply token gates what a reply resumes. The route count line now
says every route instead of a number that goes stale.
The skip on ErrRunConflict was silent, so a conflict that kept coming
back would loop every interval with nothing in the logs. It now logs a
warning with the run id and the error type, nothing else.
… through nexus

The nexus adapter now sends every call under a nexus tenant. It takes the
tenant from pipeline.WithTenantID on the context, or from a
cortex.TenantFunc over the scope (cortex.RootTenant by default), and
refuses a call it can't name a tenant for with an error matching
cortex.ErrNoScope, before anything reaches the gateway. Each call carries
usage metadata: one pair per scope level plus the run and agent the
engine now sets on every request, cut to nexus's key, value and count
limits.

New adapter options: WithTenant, WithMetadata, WithProvider and
WithSamplingUnsupported (temperature is dropped for the Claude 5.5 ids by
default). NewFunc covers a gateway that only exists once nexus starts,
and the Forge extension passes options through WithNexusOptions without
changing the option order.

Native provider content (signed thinking blocks) comes back on
llm.Response and the final stream chunk. The engine keeps it on the
assistant message, so it survives a pause, a hand-off and a reclaim and
goes back to the model, but never into conversation memory. A guard block
from nexus surfaces as llm.ErrContentBlocked and ends the run with
safety_block, with no tool from that step dispatched. Task-bound calls
keep their metadata beside the bound payload, so request digests don't
change.

Pins nexus v1.9.1 in the root and extension modules; forge v1.13.2 and
grove v1.10.0 are unchanged.
…redaction

A turn whose text publication redacted loses its thinking blocks on
replay, and native content holds the model's unredacted output at rest.
A test pins what the engine sends for a redacted turn.
Shield v1.9.1 evaluates profiles for real, so the adapter now uses them.
Every content scan runs under a shield tenant taken from the run's scope
(cortex.RootTenant by default, WithTenant to map it) and the app from
WithApp, and evaluates the agent's shield_profile. The old refusal of a
named profile is gone. A scan that can't run returns safety.ErrUnavailable
joined with the shield sentinel it matched and nothing else of shield's
error, so the text, the profile name and the tenant stay out of logs.

The adapter is also a safety.PublicationGate, and EngineOption installs it
as both scanner and gate. Inbound tool results, tool errors and retrieved
knowledge go to shield as untrusted, so the model reads shield's quoted
text (published as redacted). safety.DecisionFromScan exports the mapping
ScannerGate already used.

NewToolAuthorizer puts shield's tool-call scan under the host's
authorizer. The host answers first and its denial is returned as is.
Otherwise the two answers go through scan.Stricter: shield can deny a call
or ask for approval, never allow one the host refused, and a host approval
keeps its own reason and TTL.

The extension takes adapter options through WithShieldOptions. The
storeless-shield test is replaced by tests on an engine seeded with
presets.InfraAgent.
A run in o1 that names o2's collection by the parsed ID gets
weave.ErrCollectionNotFound and no o2 chunks, while o2 still reads it.
With "retrieval" in the default untrusted sources, every run with a
knowledge section failed: the engine re-checks a prepared run's knowledge
before each pass and expects the bytes it stored, but quoting a quote
wrapped it again. Now text that is exactly one shield quote for its source
has its inner text scanned as untrusted, so when shield changes nothing
the same bytes come back. A forged or partial wrapper is still quoted,
with its tags escaped, and an untrusted result that isn't one quote is
refused.

RunAgent, StreamAgent and an approved resume of a knowledge-backed run
now complete under the real adapter with the default sources, with the
retrieved text in exactly one wrapper. The fail-closed table also covers
shield.ErrProfileInvalid.
MIGRATION.md now says that shield gates publication even next to
engine.WithSafety, that its tenant is the scope's first level, and that
shield_profile is honoured. The authorization page shows how to block
secrets in tool arguments, and its table covers a scan that can't run.
The shield adapter is now a safety.IncrementalGate, so you can set
safety.ModeCheckedSegments and stream approved text as the model writes
it. Each segment ends after a line break or a sentence end. A run with
neither is cut at its last whitespace once it reaches WithSegmentLimit
(2048 bytes by default), so a word is never split. A block or a scan
that can't run ends the stream, and held text is never published.
A colon no longer ends a segment, and the limit cut skips whitespace
next to ':' or '=', so a credential assignment reaches shield with its
key. A PEM block is held from its BEGIN line until its matching END
line or the final check, so the private key detector sees the whole
block. Streamed output now publishes what a whole-message check does
for these cases. The docs say exactly what is held whole.
A blank run at the end of pending may sit before an assignment operator
that hasn't arrived yet, so the limit cut no longer takes it. Without
this, "key = value" and "\"key\" : value" could split when the limit
fell right after the key's blank, and the value went out in clear.
WithRunLease now defaults to zero, which leaves leasing off. A default
engine drains on Stop the way v1.9.0 did: every admitted run finishes,
nothing is handed off, and no run becomes reclaimable, so the lapsed
sweep has nothing to do.

Turn it on with WithRunLease(ttl) once your host wires RunReady and
RunReclaimable to a job that calls ContinueRun, since nothing in cortex
does. The tests that exercise leases, hand-offs and reclaims now ask for
a lease explicitly.

ResolveCheckpoint still records the decision before the run continues.
When the continuation fails before its claim, the error now says the
decision is recorded and ContinueRun finishes the run.
A run the messaging bus starts for a directive kept its reply route, the
asker's reply token included, in the run's metadata, so run JSON, events
and REST responses carried it. The route now lives in Run.ReplyRoute,
which only the engine reads and which is never serialized. Stores write
it once, at creation, in a new reply_route column (migration
20261101000004 on SQLite and PostgreSQL) and never rewrite it.

The lease owner names the worker's host, so Lease.Owner is no longer
serialized either; the expiry still is. A reclaimed run that stops on an
uncertain call says "another worker" in its error. The owner stays in
the store and the progress snapshot.

A conformance case pins the column on all three stores, and a REST test
checks GET /runs/{id} carries neither the route nor the owner.
tool.New returns a definition and a handler, and neither can carry the
retry-safe mark to the engine, so it used to drop tool.RetrySafe()
without a word. A host that relied on it got runs that stop as uncertain
after a crash. New now panics with a message that points at
tool.Register or engine.WithToolRetrySafe, the same way it already
panics on an input type it can't build a schema for.
cortex.ErrEngineStopping and cortex.ErrRunInterrupted both came back as
500. A stopping replica now answers 503, so your client can retry on
another one, and a run that was handed off answers 409: it is waiting to
be continued, not failed. Neither response carries the wrapped error.
A default engine's Stop lets the run finish and hands nothing off, its
lease ttl is zero, and the defaults test now lists run leasing among the
switches that stay off. With WithRunLease set, Stop hands the run off
and another replica's continuer job finishes it with ContinueRun, as the
docs show.

A reclaimed run is authorized as the alice who started it, never as the
job continuing it, for the replayed call and the one after. When the
codec reports alice revoked, the reclaim fails the run with
ErrPrincipalRevoked before any model call or tool.

A ResolveCheckpoint whose continuation is refused before its claim keeps
the decision, leaves the run ready, and says so in its error.
MIGRATION.md, the runs and configuration pages, the Forge guide, the
README and the CHANGELOG no longer say runs are leased by default. They
say leasing is off unless you pass WithRunLease, what turning it on
needs (a job that continues RunReady and RunReclaimable runs with
ContinueRun, since nothing in cortex does), and show the plugin that
queues that job. The retry-safety advice now applies to hosts that turn
leasing on.

The upgrade guide gains the Weave tenant change, the ResolveCheckpoint
error, clock sync between replicas, and keeping v1.9.0's experimental
reply recovery off during a leased rollout. The runs page says task-claim
runs that reach ready belong to coordination, and the MCP page says to
filter tools whose readOnlyHint you don't trust. The CHANGELOG lists the
reply token, worker id, tool.New and REST status fixes, the new
migration, and the known limits, and the [1.13.0] note now says v1.7.0,
which matches git.
On a default engine, an approval that arrived while Stop drained, or
under a full run_concurrency cap with a caller deadline, recorded the
decision and left the run ready, then failed to continue it. v1.9.0
refused both before writing anything.

A decision that continues a run (an approval, or a rejection that goes
back to the model) is now admitted first, and the admission is held
through the continuation. A refusal says nothing was recorded, and the
checkpoint stays pending. The continuation runs on that same ticket
rather than a nested one, so with leasing on it still hands off when
Stop drains. A rejection that fails the run, and a checkpoint the host
wrote itself, only record and need no slot, as before.
The engine refuses every request at admission while it stops, before
it writes anything, ResolveCheckpoint included. The 503 body now says
so, so your client knows a retry on another replica is safe.
Hooks run on the engine's own goroutines, Stop's hand-off among them,
so the continuer the docs show enqueues with a select and a default.
The test's copy now does the same.
The continuer example in MIGRATION.md and the runs page enqueues
without blocking and calls ContinueRun from a worker goroutine. Hooks
run on the sweeper, on Stop's hand-off and in the request that decided,
so a blocking send could hold Stop up until its deadline. A dropped
notice comes back with the sweeper's next renotify pass.

MIGRATION.md, the suspension page and the CHANGELOG say an approval
refused while the engine drains, or for want of a run slot, records
nothing. The custom store guide says to persist Run.ReplyRoute at
creation and leave it alone on update, as with Principal.
Comment thread store/postgres/migrate_lock.go Fixed
gosec G404 fails CI on math/rand, and the standalone gosec run ignores nolint comments. The jitter only spreads retries, but crypto/rand costs nothing here and keeps the scan clean.
On main the engine and SQLite packages already took about 25 minutes each under race coverage in CI. This branch's tests push the engine package past 30, so the coverage run timed out with no failing test.
@juicycleff
juicycleff merged commit f5c15f2 into main Oct 30, 2026
24 checks passed
@juicycleff
juicycleff deleted the feat/ai-agent-runtime branch October 30, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants