Skip to content

fix(control-plane): gate every dispatch lane before persistence and complete the rejection contract - #1033

Merged
AbirAbbas merged 9 commits into
mainfrom
fix/ext-cp-execute-admission
Aug 31, 2026
Merged

fix(control-plane): gate every dispatch lane before persistence and complete the rejection contract#1033
AbirAbbas merged 9 commits into
mainfrom
fix/ext-cp-execute-admission

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

Finishes the execute-admission contract that #1001 started. The async lane already gated before persisting; the sync, restart and MCP lanes still persisted the payload blob and both execution rows before the per-agent concurrency / LLM-circuit gate, so a rejected burst left orphan failed rows and blobs behind. Now every dispatch lane admits first and persists after; a request admitted right as the async pool stops is terminalized (failed / control_plane_shutdown) through a detached bounded persistence context instead of being abandoned in running; every time-retryable rejection (429 concurrency_limit, 503 queue-full / pool-stopped / node_unavailable / llm_unavailable) carries both a Retry-After header and a retry_after body field, with llm_unavailable advertising the circuit-breaker's real recovery window; and the README/docs no longer claim a "durable PostgreSQL queue with lease-based processing" that does not exist — the copy now describes the actual bounded in-process admission model.

Why

Refs #986. Rejections were supposed to be cheap; on three of the four lanes they wrote to disk first. The stale README claim is very likely what set the expectation that a burst would queue rather than 503.

Changes

  • fix(control-plane): admit executions before persistence on every dispatch lane
  • test(control-plane): cover the execute admission gate and rejection contract
  • docs: describe the real execute admission model, not a lease-based queue
  • test(control-plane): assert the restart lane persists the category it answers with
  • fix(control-plane): terminalize pool-stopped restart/MCP admissions through a detached context
  • style(control-plane): gofmt the admission fix

Validation contract

  • A sync execute rejected by the per-agent gate returns 429 + Retry-After: 1 + error_category: concurrency_limit and creates no rows and no payload blob → TestExecuteHandler_ConcurrencyRejectionHasNoPersistence
  • A sync execute rejected by the open LLM circuit returns 503 llm_unavailable and persists nothing → TestExecuteHandler_LLMUnavailableRejectionHasNoPersistence
  • Replay hits bypass the gate on both lanes and consume no slot → TestExecuteHandler_ReplayHitNotGatedBySaturatedAgent
  • Restart / MCP gate rejections persist nothing and return the async lane's body shape → TestRestartHandler_ConcurrencyRejectionHasNoPersistence, TestMCP_ExecuteReasonerConcurrencyRejectionHasNoPersistence
  • Restart queue-full 503 carries Retry-After + retry_afterTestRestartHandler_QueueFullCarriesRetryAfter
  • Per-agent slot accounting balances across success / agent error / precondition rejection / replay / queue-full / pool-stopped → TestExecuteHandler_SlotBalancedAcrossOutcomes + the rejection tests above
  • A pool-stopped admission terminalizes the already-persisted rows as failed / control_plane_shutdown on both tables, even when the request context is already cancelled, on the async, restart and MCP lanes → TestExecuteAsyncHandler_PoolStoppedTerminatesPersistedRow, TestRestartHandler_PoolStoppedPersistsMatchingStatusReason, TestMCP_ExecuteReasonerPoolStoppedTerminatesPersistedRowsWithCancelledRequest, TestMCP_ExecuteReasonerPoolStoppedExercisesPersistenceFailure
  • Retry-After per category, with 413 and agent_pending_approval carrying none → TestWriteExecutionError_RetryAfterPerCategory
  • llm_unavailable advertises the circuit recovery window (default 30s, floor 1s), computed in services → TestLLMHealthMonitor_RetryAfterSeconds
  • The restart lane persists the same status_reason it answers with → TestRestartHandler_PoolStoppedPersistsMatchingStatusReason
  • The ratelimit middleware's 429 paths are untouched (empty diff)
  • grep -rn "lease-based" README.md docs/ returns nothing

How it was tested

CI-literal gates in the worktree: go build, gofmt -l, go vet, full control-plane suite (-tags sqlite_fts5, minus internal/packages), ./scripts/coverage-surface.sh control-plane, ./scripts/patch-coverage-gate.sh (≥80 % on touched lines) — ALL-PASS. Rebased onto current main before push. An adversarial review ran between the first four commits and the fix commit; its one blocking finding (restart/MCP pool-stopped persistence using the request context — the same bug class #1001 fixed on the async lane) is what the fix commit addresses.

Notes / follow-ups

The gate halves are opt-in and off by default (AGENTFIELD_MAX_CONCURRENT_PER_AGENT=0 = unlimited; llm_health.enabled=false), so a stock deployment sees no behavioural change. The bigger asks on #986 — admit-and-queue at capacity, a queued persisted status, holding the per-agent slot until the terminal callback — are design decisions tracked on the issue, not smuggled in here.

🤖 Generated with Claude Code

AbirAbbas and others added 6 commits August 31, 2026 14:40
…atch lane

The per-agent concurrency limit and the LLM circuit breaker were checked
before persistence only on the async lane. On the sync, restart and MCP
lanes the check ran *after* prepare had already written an executions row,
a workflow_executions row and an input payload blob, so a gate-rejected
request was charged a failed execution for work that was never attempted.

There is now a single admission point, ahead of persistence, on all four
lanes: prepareExecutionForTargetWithAdmission takes acquireSlot=true from
the sync handler, the restart handler and the MCP start_run path, and the
duplicate post-prepare gate blocks are gone. findReplayHit moves ahead of
the gate so a replay hit — which never dials the agent — is never rejected
by it and consumes no slot; the async lane no longer acquires and releases
a slot for one. preparedExecution.slotHeld records whether a plan actually
owns a slot, so every release site releases exactly what it took.

Two other holes in the rejection contract close with it:

- handleAsync abandoned the already-persisted row in "running" when
  submitReserved found a stopped pool. It now terminates it through
  failForControlPlaneShutdown (failed + status_reason
  control_plane_shutdown on both tables), on a detached context because
  the request context is very likely being cancelled by the same drain.
- The restart and MCP queue-full paths reserved pool capacity only after
  prepare, so a queue-full burst wrote rows and then failed them, and the
  restart lane persisted status_reason internal_error while answering
  concurrency_limit. reserve() is hoisted ahead of prepare on both, and a
  single typed executionPreconditionError now feeds both failExecution and
  the response.

Retry-After is completed at the same time: writeExecutionError takes the
value stamped on the error, else a per-category default, so llm_unavailable
advertises the circuit breaker's remaining recovery window (default 30s,
floor 1s) via the new LLMHealthMonitor.RetryAfterSeconds — circuitOpenedAt
is unexported, so the window has to be computed inside services — while
concurrency_limit and node_unavailable stay at 1 and non-retryable
rejections (413, agent_pending_approval) still carry nothing.

The stale reservation comment above pool.reserve() is corrected: the worker
releases the reservation when its job returns, not on dequeue, so a
reservation covers preparation, queue wait and the whole dispatch.

Both gate halves are opt-in and off by default
(AGENTFIELD_MAX_CONCURRENT_PER_AGENT=0, llm_health.enabled=false), so a
stock deployment sees no behaviour change.

Refs #986

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ontract

One test per observable behaviour of the new admission point, written from
the caller's side (HTTP status, headers, body, and what the store holds
afterwards) rather than from the implementation:

- sync concurrency and llm_unavailable rejections persist no executions
  row, no workflow_executions row and no payload blob;
- a replay hit against an agent already at its cap still returns 200/202
  with X-AgentField-Replay-Hit, never dials the agent, and consumes no
  slot;
- the per-agent running count is 1 during a successful sync call and back
  to 0 after success, an agent 5xx and a pre-gate precondition rejection;
- restart rejections (gate and queue-full) persist nothing and carry
  Retry-After plus retry_after;
- an async request whose pool stops between reserve() and submitReserved
  ends as failed/control_plane_shutdown on both tables with the slot
  released exactly once;
- writeExecutionError's Retry-After table, including that 413 and
  agent_pending_approval carry neither header nor field;
- LLMHealthMonitor.RetryAfterSeconds counts the window down, floors at 1,
  and falls back to the configured recovery timeout (30s) when the circuit
  is closed, the endpoint is unknown, or the receiver is nil;
- an MCP start_run rejected by the gate persists nothing.

Two existing fixtures build a preparedExecution by hand after acquiring a
slot themselves; they now set slotHeld so the job still releases what they
took. TestPrepareExecution_AdditionalCoverage pins the process-global
limiter to nil, because prepareExecution now acquires a slot and its four
direct calls would otherwise leak counts into unrelated tests.

Refs #986

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README advertised "a durable PostgreSQL queue with lease-based processing,
so a crash or a restart resumes where it left off". No such thing exists:
the lease columns in migrations 011 and 013 are inert plumbing, and there
is no acquisition, renewal or expiry-reclaim code anywhere in the tree.
What the control plane actually does is admit work into a bounded
in-process queue with backpressure (429/503 plus Retry-After) and, on
graceful shutdown, terminate in-flight executions with status_reason
control_plane_shutdown instead of silently dropping them. Both README
claims now say that.

Alongside it:

- docs/api/EXECUTE.md said llm_unavailable carried no Retry-After. It now
  does, advertising the circuit breaker's remaining recovery window, and a
  new line under the table states that these pre-dispatch rejections
  persist no rows — with the one exception of a request rejected after
  preparation because the pool has already stopped.
- docs/api/EXECUTION_RESTART.md records that the restart lane runs the same
  admission checks before persistence and returns Retry-After on queue-full.
- AGENTFIELD_EXEC_ASYNC_QUEUE_CAPACITY was documented as the number of
  executions "waiting for a worker". The admission bound is really
  workers + queue_capacity and a reservation is held across preparation,
  queue wait and the worker's dispatch (up to 24h for a paused execution).

Refs #986

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… answers with

The restart handler can still lose the race between reserve() and
submitReserved when the pool stops in between. That branch is the one that
used to answer concurrency_limit while writing status_reason
internal_error, because the queue error was an untyped errors.New. Drive it
through the same CreateExecutionRecord seam the async pool-stopped test
uses and assert the persisted status_reason equals the error_category in
the body, that Retry-After and retry_after are both present, and that the
per-agent slot is released.

Refs #986

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hrough a detached context

The restart and MCP submit-failure paths persisted the terminal state with
the request context — during shutdown that context is likely already
cancelled, stranding the freshly created rows in running (the same bug
class #1001 fixed on the async lane). MCP also discarded the persistence
error entirely. All three lanes now share one helper: detached bounded
persistence context, failed/control_plane_shutdown on both tables, and a
warn log carrying node_id and execution_id when persistence itself fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.80% 87.40% ↑ +0.40 pp 🟡
sdk-go 93.10% 92.00% ↑ +1.10 pp 🟢
sdk-python 94.33% 93.73% ↑ +0.60 pp 🟢
sdk-typescript 91.72% 90.42% ↑ +1.30 pp 🟢
web-ui 84.77% 84.79% ↓ -0.02 pp 🟡
aggregate 85.89% 85.75% ↑ +0.14 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 283 92.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@AbirAbbas
AbirAbbas merged commit 2638b9e into main Aug 31, 2026
27 checks passed
@AbirAbbas
AbirAbbas deleted the fix/ext-cp-execute-admission branch August 31, 2026 23:04
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.

1 participant