Skip to content

feat(mcp): dispatch returns a Job handle once Tasks are persisted; a re-issued call never double-dispatches - #957

Merged
rapp992 merged 5 commits into
mainfrom
loop/issue-895-mcp-job-handle
Oct 9, 2026
Merged

rapp992 merged 5 commits into
mainfrom
loop/issue-895-mcp-job-handle

Conversation

@rapp992

@rapp992 rapp992 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Closes #895

Part of #392 (the umbrella stays open: the Control API's Idempotency-Key and its non-blocking return are not built here, so #392's "must hold for the Control API as well as MCP, or the difference must be a written decision" is not yet satisfied).

What changed

  • run_operation and raw_exec now return the Job as soon as every Task is persisted Pending, with job_id and job_status: "running"; the caller polls get_job (which now also reports job_status: running | complete). The fan-out and the post-dispatch accounting (the dispatch audit record, metrics) finish in the background on a detached context. cancel_job still covers the whole background lifetime.
  • wait_seconds (0-300, new optional input) is the blocking form: it waits up to that long for every Task to finish and returns full per-Node results if they did, otherwise the running Job. A lapsed wait never cancels the Job. The ceiling (300 s, equal to the per-Task hard wall clock) is in the schema and the tool descriptions. The issue says "behind the existing MCP option", but no such option existed. wait_seconds builds ADR-0004's "optionally blocking up to a short wait budget".
  • idempotency_key (new optional input, at most 128 printable ASCII): a repeat of the same key by the same Account within 24 h returns the existing Job (reattached: true) and dispatches nothing. The same key with different arguments is refused. A key with no Account, or with no store wired, is refused rather than ignored. Every outcome that discloses a Job ID is an audited read (ADR-0025). The capability layer alone decides each outcome (invariant 10):
    • re-attach: the original Job's Tasks are persisted;
    • pending: the original is still persisting, or it crashed less than 10 min ago; the caller is told to poll or re-issue with the same key, and never to use a new one;
    • released: the original failed before dispatching; the caller re-issues with the same key;
    • abandoned: more than 10 min old, with no Tasks and no live claimer. This is the only message that says a new key is safe.
  • Capability layer:
    • New Service.Start returns a StartedJob. Run is refactored into prepareDispatch + account and behaves exactly as before for the Control API and Console.
    • New Service.Drain is wired into every test fixture that mounts MCP.
    • The key is claimed with a reserved Job ID before the approval BindJob and before any Job exists (the AP-77 pattern). A lost BindJob rebinds the key to the winner's Job.
    • The key lookup sits after Freeze and Authorize, and before zero-match, the blast cap and approval. A sequential re-issue never spends a Grant use.
  • Store: migration 0029_dispatch_idempotency_keys, a new table. It is Account-scoped, uses integer-nanosecond timestamps, and sweeps expired rows inside Claim.
  • The Console no_dispatch guard now treats Start and Drain as write verbs, with fixtures (security review finding 1).
  • plan: true ignores idempotency_key entirely. A preflight writes no dedup row (PF-8), and this is tested.
  • Breaking change for MCP clients (Gleipnir, demo scripts): the default result now carries job_status: "running" and unspecified outcomes until polled. Pass wait_seconds for the old behaviour. docs/STATUS.md, docs/dev-fleet.md, docs/operations/demo-fleet.md and docs/operations/beta-hardware-validation.md are updated accordingly.

Acceptance:

  • (a) TestMCP_RunOperation_SilentNodeReturnsTheJobOncePersisted. The call returns while node-a's dispatch is provably still blocked, and both Tasks are already persisted. The Job later reports unreachable for the never-connected node-b. Note: this holds per Node. A connected Node that never answers keeps its Task Dispatched (restart reconciliation; unacknowledged is not built), so it does not itself resolve Unreachable.
  • (b) TestMCP_RunOperation_ReissueAfterDroppedResponseReturnsTheSameJob. The first call's response is dropped mid-Job, the call is re-issued on a new request, and the result is the same job_id with reattached: true, one Job in the store and one dispatch to the Node. The composition-root twin is TestMCPExecution_ReissuedCallWithTheSameKeyReturnsTheSameJob.

Branch contents not authored by this issue

Overlap with open PR #954 (#943, cancel re-check)

This PR stays orthogonal to #954. In execution.go it changes only jobEngine.run, split into persist/fanOut, and adds start/jobPersisted. It leaves the file header, dispatchOne, dispatchOneRecovered, cancel, the router, the reconciler and the send guard untouched. Expect at most a textual rebase conflict. The cancel gate is opened in persist and released when fanOut returns, so #954's send guard covers background Jobs too.

Verification

  • make verify green: env -u GH_TOKEN RELAY_SMOKE_PORT=20895 GOLANGCI_LINT_CACHE=<worktree>/.dev-loop/golangci-cache make verify, exit 0, on the final tree.
    • Lanes: fmt, lint-relay, lint-daemon, check-daemon-bans, lint-devharness, devharness-absent-check, test-relay-race, test-daemon, test-strict-umask, opschema-check, openapi-check, apiclient-check, verify-proto, docs-check, test-security-surface, test-codex-review, test-ci-changed-paths, test-check-docs, test-smoke-test, test-maintainer-check, test-release-image-guard, test-dev-fleet-make, test-integration, test-crossbinary, lint-ci, secrets-scan, deny-daemon, test-vuln-gate.
    • Fix cycles to reach green:
      • gosec G115 in RequestDigest: fixed at the root, with no nolint.
      • A Control API body that had picked up an MCP-only field in one e2e test: fixed.
      • A race-run flake: root-caused to settle() closing settled before removing the claim from the map, fixed, and covered by a regression test.
      • A gofmt alignment: fixed.

Gates skipped:

  • check-crosslinks milestone half: gh api is unauthenticated here. CI runs it.
  • The govulncheck network scan is CI-only. test-vuln-gate locally is the script self-test.
  • /proto is untouched. MCP tool schemas changed, but nothing moved in opvocab, OpenAPI or the apiclient, and opschema-check, openapi-check and apiclient-check all passed.

Reviews

  • Claude plan review: REVISE, then APPROVE (2 cycles). Five cycle-2 suggestions were folded in.
  • Claude code review: cycle 1 was CHANGES_REQUESTED: an identifier used the GLOSSARY-avoided term "job handle", plus five suggestions. All were fixed. Cycle 2 was CHANGES_REQUESTED with one blocking finding: the settle() ordering race, which gave a wrong but harmless "pending" answer and never a dispatch. It was fixed after the final code-review cycle, with a regression test, and the security review below examined the fix explicitly. The two cycle-2 suggestions were also applied.
  • Claude security review: CONCERNS.
    • Finding 1 (the Console no_dispatch guard lacked Start/Drain): fixed.
    • Finding 3 (Drain not called in production): the doc comment is corrected, and the gap is recorded under Escalations.
    • Finding 2 (back-pressure): not fixed; Unresolved, see below.
    • Findings 4 and 5 are informational and listed below.
  • Codex: once, final diff — absent (reason=not-installed; no model was selected, because scripts/codex-review.sh exits 3 before choosing one when the plugin, CLI or node is missing). The gate ran with one reviewer, so the whole issue is single-vendor.
  • scope-partial: n/a.

Capability spec

  • The governing spec in docs/specs/ is still true of the code after this change
  • Or: no capability spec covers this area yet

Spec: docs/specs/execution.md, Status aspirational (legacy, context only, so no proof rows and no claims promoted). Bound specs checked for conflicts: approval.md (AP-60…AP-71 and AP-77 preserved; proof-row test names unchanged and only wait_seconds added) and policy-preflight.md (PF-2 and PF-8 preserved and tested). Divergences from execution.md's stated intent, for the spec author:

  • execution.md puts idempotency first in the pipeline. Here the lookup comes after Freeze and Authorize, which is more restrictive. Consequence: during a Freeze, a re-issue cannot recover its Job ID.
  • execution.md calls for an "operator-set window". Here the window is a fixed 24 h constant, and the 10 min abandon bound is also a constant. Adding operator configuration was left as a human decision (invariant 11).
  • execution.md says the Job ID comes back "within a short bounded wait". Here the default wait is 0, following the issue's literal Target ("as soon as every Task is persisted"), with a 300 s ceiling. Please confirm both numbers.

Security surface

  • Touches none of: /proto, Policy, execution, transport, mTLS, auth, blast caps, label authority
  • Or: names the invariant below and has a human reviewing it deliberately

Paths and why:

  • relay/internal/store/{idempotency.go,store.go,migrations/0029_dispatch_idempotency_keys.sql}: the new key table and its repository.
  • relay/internal/capability/{service.go,start.go,execution.go,dispatch.go,seams.go}: Start/Drain, the claim state machine, Run split into prepareDispatch/account, the engine's run split into persist/fanOut/start, and the RefusalMalformed doc comment.
  • relay/internal/controlplane/{execution.go,render.go} and relay/internal/controlplane/mcp/{tools_execution.go,tools_jobs.go,wire.go,mcp.go}: the new MCP inputs and outputs, and the job status rendering.
  • relay/cmd/relay/main.go: wires capability.WithIdempotency.
  • relay/internal/controlplane/console/no_dispatch_test.go: the guard is tightened.
  • .github/workflows/*: the ci: repin dtolnay/rust-toolchain to the current v1 (upstream input-handling fix) #956 port, a SHA repin only.
  • Test files throughout.

Invariants touched:

  • 4, at-most-once: a keyed re-issue never reaches the dispatcher. Concurrent same-key calls produce exactly one Job (a race test). Compensating writes run on a detached, bounded context. A re-issue without a key is still a new dispatch, deliberately: the Relay never infers a repeat from content.
  • 5, blast cap: unchanged, and enforced before any claim.
  • 10, one capability layer: every outcome is decided in capability. The adapter forwards the key, narrows the schema and chooses the wait.
  • 11: no new configuration.
  • 13: risk class untouched; RequestDigest excludes it.

Escalations

Unresolved (needs a human; the coordinator should apply loop:needs-human to this PR):

  • Back-pressure (security finding 2). A non-blocking dispatch no longer holds a connection for the Job's lifetime, and MCP has no rate limit. One Account can therefore start an unbounded number of concurrent background Jobs, each still within the blast cap. The fix is a global in-flight Job ceiling in Start, but its value, its refusal kind, and whether it is a constant or a startup flag (invariant 11) are a capacity-contract decision for a human. Not implemented. The alternative is to accept the risk explicitly.

For the human / owner-authored artifacts (the loop may not write these):

  • An ADR-0004 amendment, or a new ADR, is owed for client-level idempotency: a new table and a public MCP argument, which is hard to reverse. execution.md already says "ADR-0004 — needs amending". This is not a docs chore.
  • GLOSSARY.md has no entry for "idempotency key". It lists "job handle" under Avoid, while this issue's title (and so this PR's title) uses it. Code, comments and test names say "the Job" / "Job ID".
  • wait_seconds and idempotency_key are new public MCP surface, and the non-blocking default is a breaking contract change for existing clients.
  • Drain is not called in production (cmd/relay has no shutdown path). On process exit, a background Job's post-dispatch dispatch audit record and metrics can be lost. At-most-once still holds through the dispatch intent, which commits with the Tasks (fix(audit): an intent row commits with CreateTask so a crash cannot leave an executed Task unrecorded #948), and through daemon dedup. A bounded shutdown hook is follow-up work.
  • A dropped MCP stream no longer stops a Job. Only cancel_job does; this is what the issue asks for.
  • Single-instance premise: the in-process "pending" wait and the 10 min abandon bound assume one Relay process per store.
  • wait_seconds and idempotency_key are part of an answered retry's sealed digest (AP-65): re-send the call unchanged. The schema text says so.
  • Migration number 0029 was free at branch time; renumber it at rebase if another PR takes it.
Implementation plan (approved after 2 plan-review cycles)
IMPLEMENTATION PLAN
===================
Issue: #895 mcp: dispatch returns a Job handle once Tasks are persisted; a re-issued call never double-dispatches
Spec: docs/specs/execution.md   Binding: legacy (aspirational), context only. Also checked for conflicts: docs/specs/approval.md, which is migrated and binding (AP-60…AP-71, AP-77), and docs/specs/policy-preflight.md, which is migrated and binding (PF-2, PF-8).
Claims: none

Summary:
  This adds `capability.Service.Start`. It runs the same pipeline as `Run` and returns once the Job's Tasks and its dispatch intent are committed. Fan-out and the post-dispatch accounting finish in a background goroutine that the Service tracks and that tests can drain.

  Start takes an optional idempotency key, scoped to the Account. A re-issued call is answered with the existing Job and never dispatched again. The capability layer alone decides whether a keyed Job is persisted, still being created, or was never created.

  `run_operation` and `raw_exec` call Start. By default they return `job_id` with `job_status: "running"`. ADR-0004's `wait` budget becomes the opt-in blocking form, `wait_seconds` (0–300). `Run` is unchanged for the Control API and Console. #392 stays open, because the Control API is not covered.

Findings that shaped the plan:
  - **There is no existing MCP option for the blocking form.** `mcp.New` takes only WithClock, WithApprovalDecider, WithApprovalElicitation and WithToolCallObserver. The tool inputs are selector, operation, args, timeout_seconds and plan. The only precedent is ADR-0004 (Accepted): "the call returns a Job ID (optionally blocking up to a short `wait` budget)". `wait_seconds` builds that budget. It is adapter presentation and narrows only.
  - **ADR-0028 §Scope limits task handles to parked dispatches.** The result is therefore an ordinary tool result carrying `job_id`, never a CreateTaskResult. `taskshim.go` is untouched; it rewrites only `structuredContent.pending_approval`.
  - **GLOSSARY lists "job handle" under `_Avoid_`.** Code, comments, test names and the PR say "the Job" / "Job ID". The capability type is `StartedJob`.
  - **No bound claim requires the execution tools to block or to return results inline.**
    - AP-60…AP-71 and AP-77 are preserved, because Start shares Run's pre-dispatch half unchanged and returns the same `*ParkedError`.
    - Proof-row tests that assert inline results keep their names and pass `wait_seconds`.
    - PF-8 ("a preflight creates nothing … no dedup entry") is honoured: `plan: true` never reaches Start (S3).
  - **A connected Node that never answers keeps its Task Dispatched.** It is reconciled on restart; `unacknowledged` is not built. Acceptance (a) is read per Node: the call returns while a Node is silent, and the Job later reports `unreachable` for the Node that can never answer.
  - **Persisted is a precise signal.** The engine's Tasks and dispatch intent commit in one transaction (`RecordIntent`, #887), and a zero-match Selector is refused before any Job exists. So "the Job has ≥1 Task" is exactly "the Job is persisted". `TaskRepository.ByJob` already exists.
  - **`busyTimeoutMillis = 5000`** (store.go:94). Persist does a handful of local writes, so its worst case is tens of seconds. Ten minutes is far beyond that.

Affected files (in implementation order):
  1. relay/internal/jobs/idempotency.go — create
     - `type IdempotencyRecord struct { AccountID, Key, RequestDigest, JobID string; CreatedAt time.Time }`.
     - `type IdempotencyRepository interface`:
       - `Claim(ctx, rec IdempotencyRecord, notBefore time.Time) (existing IdempotencyRecord, claimed bool, err error)`. It sweeps expired rows, inserts `rec`, and if a live row exists returns it with `claimed=false`.
       - `Lookup(ctx, accountID, key string, notBefore time.Time) (IdempotencyRecord, bool, error)`.
       - `Rebind(ctx, accountID, key, fromJobID, toJobID string) error`.
       - `Release(ctx, accountID, key, jobID string) error`. Both Rebind and Release are conditional on the row still naming `fromJobID` / `jobID`.
     - `func RequestDigest(selector string, a Action) string`:
       - Hex SHA-256 over length-prefixed fields: selector, Kind, OperationName, OperationArgs (sorted by key, as k/v pairs), RawExecArgv, TimeoutSeconds.
       - RiskClass is excluded, because the capability layer derives it.
       - No `database/sql`, so nosql_test stays green.
  2. relay/internal/store/migrations/0029_dispatch_idempotency_keys.sql — create
     - `CREATE TABLE dispatch_idempotency_keys (account_id TEXT NOT NULL, idem_key TEXT NOT NULL, request_digest TEXT NOT NULL, job_id TEXT NOT NULL, created_at_ns INTEGER NOT NULL, PRIMARY KEY (account_id, idem_key));`
     - `CREATE INDEX dispatch_idempotency_keys_created ON dispatch_idempotency_keys(created_at_ns);`
     - The header comment covers:
       - the table is Account-scoped and holds only the verified AccountID;
       - there is no backfill;
       - why the timestamp is an INTEGER (S7, below).
     - Confirm 0029 is still free on main at rebase time. 0028 is the latest today, and PR #954 adds no migration.
  3. relay/internal/store/idempotency.go — create
     - `sqliteIdempotency{db}` implements `jobs.IdempotencyRepository`. Times are stored as `t.UTC().UnixNano()` and read back with `time.Unix(0, ns).UTC()`.
     - `Claim` runs in one transaction, following approvals.go BindJob's tx pattern:
       1. `DELETE FROM dispatch_idempotency_keys WHERE created_at_ns < ?` with `notBefore`. This is the S6 sweep, mirroring mcptasks.go:148's expiry delete.
       2. `INSERT … ON CONFLICT(account_id, idem_key) DO NOTHING`.
       3. If RowsAffected is 0, SELECT the live row and return `claimed=false`.
     - `Lookup`: `WHERE account_id=? AND idem_key=? AND created_at_ns >= ?`.
     - `Rebind`: `UPDATE … SET job_id=? WHERE account_id=? AND idem_key=? AND job_id=?`.
     - `Release`: `DELETE … WHERE account_id=? AND idem_key=? AND job_id=?`.
     - Add `func (s *Store) IdempotencyKeys() jobs.IdempotencyRepository` next to `MCPTasks()` in store.go.
  4. relay/internal/capability/execution.go — modify `jobEngine.run` only, plus two new methods
     - Do not touch the file-header at-most-once note, `dispatchOne`, `dispatchOneRecovered`, `cancel`, `resolve*` or any `withhold*` helper. PR #954 edits those.
     - Split today's `run` body (lines 248–461) into:
       - `persist(ctx, d dispatch, detach bool) (*persistedJob, error)`: today's body through `RecordIntent`.
         - `runCtx` is `context.WithoutCancel(ctx)` when `reserved || detach`.
         - It opens the gate, and calls `release()` itself on every error return.
       - `fanOut(pj *persistedJob) (dispatchResult, error)`: today's post-RecordIntent body, with `defer pj.release()`.
       - `type persistedJob struct { jobID string; runCtx context.Context; gate *jobGate; release func(); pending []pendingTask; d dispatch }`.
     - `run` becomes `persist(ctx, d, false)` followed by `fanOut`, so its behaviour is byte-identical.
     - New `func (e *jobEngine) start(ctx, d) (jobID string, finish func() (dispatchResult, error), err error)`:
       - It calls `persist(ctx, d, true)`, so the whole pipeline is detached; the caller returns as soon as `start` does.
       - The comment cites the existing reserved-path reasoning (a background Job's lifetime is bounded by each Task's 300s hard wall clock).
     - New `func (e *jobEngine) jobPersisted(ctx, jobID string) (bool, error)`: `tasks, err := e.tasksRepo.ByJob(ctx, jobID)`, then `len(tasks) > 0`. The doc comment states the one-transaction equivalence above.
  5. relay/internal/capability/dispatch.go — modify
     - Add an unexported optional interface `starter { start(ctx context.Context, d dispatch) (string, func() (dispatchResult, error), error); jobPersisted(ctx context.Context, jobID string) (bool, error) }`.
     - Do **not** widen `dispatcher`. noopDispatcher and the existing test spies stay untouched.
  6. relay/internal/capability/seams.go — modify one doc comment (S8)
     - `RefusalMalformed`'s comment becomes: "a dispatch refused for malformed input: a Template's argument names not matching its slots or its matched Nodes disagreeing about them (ADR-0042, #345), or an invalid idempotency key, or a key reused with different arguments (#895). Never a Policy verdict."
  7. relay/internal/capability/start.go — create (the exported surface and the keyed re-attach decision)
     - Types:
       - `type StartOptions struct { IdempotencyKey string }`.
       - `type StartedJob struct { JobID string; Reattached bool; Done <-chan struct{} }`. `Done` closes after fan-out **and** the KindDispatch audit and metrics accounting. It is already closed when `Reattached` is set or when nothing ran in the background.
     - Constants:
       - `IdempotencyWindow = 24 * time.Hour`
       - `MaxIdempotencyKeyBytes = 128`
       - `IdempotencyAbandonAfter = 10 * time.Minute`
       - `defaultReattachPersistWait = 10 * time.Second`
       - `idempotencyCompensateTimeout = 5 * time.Second`
       - the tool names `toolIdempotentReattach = "idempotent_reattach"`, `toolIdempotentPending = "idempotent_pending"` and `toolIdempotentAbandoned = "idempotent_abandoned"`.
     - Errors. None carries the key value, because errors land in the audit Reason. Each message says "this call dispatched nothing":
       - `*IdempotencyConflictError{}`: "this idempotency key was already used by this Account within 24h with different arguments; this call dispatched nothing — use a new key for a new request". A new key is correct here, because the earlier request was a *different* request.
       - `*IdempotencyKeyError{Reason string}`: invalid key; it must be 1–128 bytes, each byte 0x21–0x7E.
       - `ErrIdempotencyUnavailable`: a key was supplied but there is no store, no AccountID, or the dispatcher is not the real engine (`starter`). Fail closed and never ignore a key.
       - `*IdempotencyPendingError{JobID string}`: "an earlier call with this idempotency key is still creating Job <id>; this call dispatched nothing. Poll get_job <id>, or re-issue this call unchanged with the same key. Do not use a new key: the earlier call may still run." It must never suggest a new key.
       - `*IdempotencyReleasedError{}`: "the earlier call with this idempotency key failed before dispatching anything, and nothing ran under it; this call dispatched nothing — re-issue this call unchanged with the same key to retry".
       - `*IdempotencyAbandonedError{JobID string; ClaimedAt time.Time}`: "this idempotency key was bound to Job <id> at <t>, more than 10 minutes ago, and that Job was never created, so nothing ran under it; this call dispatched nothing — a new key is safe". This is the **only** message that may suggest a new key.
     - `WithIdempotency(repo jobs.IdempotencyRepository) Option` sets `s.idem`.
     - Service fields, added in service.go's struct:
       - `idem jobs.IdempotencyRepository`
       - `bg sync.WaitGroup` (background Jobs)
       - `claimsMu sync.Mutex`
       - `claims map[string]*keyClaim`, keyed `accountID + "\x00" + key`
       - `reattachPersistWait time.Duration` (an unexported in-package test seam)
       - `type keyClaim struct { jobID string; settled chan struct{} }`. `settled` closes once the claimer's persist has returned, or once the claim was released or rebound.
     - `func (s *Service) Start(ctx, p, sel, act, o StartOptions) (StartedJob, error)`:
       1. Call `pr, err := s.prepareDispatch(ctx, p, sel, act, o.IdempotencyKey)` (item 8). On a refusal or park, return exactly what Run would. `*ParkedError` is unchanged.
       2. If `pr.reattach != nil`, return `StartedJob{JobID, Reattached: true, Done: closedChan}`. This covers both the approval AP-77 re-attach and the keyed re-attach.
       3. If `s.disp` implements `starter`, call `start(ctx, pr.d)`.
          - On error:
            - When a key was claimed, `Release` it on a compensate ctx (`context.WithTimeout(context.WithoutCancel(ctx), idempotencyCompensateTimeout)`) and log on error.
            - `settle` the claim.
            - Call `s.account(...)` synchronously with an empty result (today's audit-on-error behaviour).
            - Return the error.
          - On success:
            - `settle` the claim. The Job is persisted.
            - `s.bg.Add(1)`.
            - `go func(){ defer s.bg.Done(); res, err := finish(); s.account(acctCtx, …, alwaysLog=true); close(done) }()`, where `acctCtx` is `context.WithTimeout(context.WithoutCancel(ctx), reservedPostDispatchAccountingTimeout)`.
            - Return `StartedJob{JobID, Done: done}`.
       4. Otherwise (noop or a test spy; never keyed, because item 8 refuses a key without `starter`): call `s.disp.run` synchronously, account for it, and return the Job ID with `Done` closed.
     - `func (s *Service) Drain(ctx context.Context) error` waits for `s.bg` or for `ctx`, returning `ctx.Err()` on timeout. The doc comment covers:
       - It is for an owner's shutdown or a test fixture's cleanup.
       - It must be called only after every surface that could call Start has stopped serving, because WaitGroup.Add must not race Wait.
       - It dispatches nothing and changes no Job.
     - Unexported `func (s *Service) resolveKeyedReattach(ctx, p, sel, rec jobs.IdempotencyRecord) (*reattachment, error)`. This is the B1 decision, made in the capability layer and never in the adapter:
       1. If `s.claims` holds a live `keyClaim` for (Account, key) naming `rec.JobID`, wait on `settled` bounded by `s.reattachPersistWait` and `ctx.Done()`.
       2. `ok, err := starter.jobPersisted(ctx, rec.JobID)`. A store error becomes RefusalInternal. If `ok`: `recordQueryRead(ctx, p, toolIdempotentReattach, sel.String(), "", rec.JobID)`, which fails closed, then return the re-attach.
       3. Not persisted, and an in-process claim is still live: record `toolIdempotentPending` and return `*IdempotencyPendingError{rec.JobID}`.
       4. Not persisted, with no live in-process claim. `Lookup` again:
          - The row is gone (released): record the read with job "", then return `*IdempotencyReleasedError`.
          - The row now names a different Job (rebound under AP-77): run steps 2–3 once against the new Job ID, with no further recursion. If it is still not persisted, return Pending.
       5. The row still names `rec.JobID`:
          - `now - rec.CreatedAt < IdempotencyAbandonAfter`: return Pending (the claimer may have crashed moments ago, or be on another restart).
          - Otherwise: record `toolIdempotentAbandoned` and return `*IdempotencyAbandonedError`.
       - Each of these outcomes discloses a Job ID, so each is an audited read and fails closed with ErrReadUnaudited. None is a refusal metric, because none is a refusal of the request.
     - `settle(accountID, key)` closes `settled` once and deletes the map entry.
  8. relay/internal/capability/service.go — modify (refactor `Run`, behaviour preserved)
     - Extract lines 528–704 into `prepareDispatch(ctx, p, sel, act, key string) (prep, error)`. `prep` carries:
       - `d dispatch`, `ap approvalDecision`, `reserved bool`
       - `keyClaimed bool`, `key string`, `digest string`
       - `reattach *reattachment{jobID string; results []NodeResult}`
     - Extract lines 708–794 into `account(ctx, p, sel, act, pr prep, res dispatchResult, dispatchErr error, alwaysLog bool) ([]NodeResult, error)`. The reserved-path slog branch fires when `pr.reserved || alwaysLog`.
     - `Run` calls `prepareDispatch(…, "")`, returns the re-attach results if present, and otherwise calls `s.disp.run` then `account`. With an empty key it behaves exactly as today.
     - Initialise `claims` and `reattachPersistWait = defaultReattachPersistWait` in `New`.
     - Changes inside `prepareDispatch`, used only when `key != ""`:
       - At the top, before `resolveAction`, keep `reqAct := act`. The digest is `jobs.RequestDigest(sel.String(), reqAct)`, taken over the request as received.
       - **(a) Validate immediately after `Authorize`** (S5). The Freeze check stays the outermost gate, per ADR-0013 and Run's doc.
         - An invalid key gives `recordRefusal(RefusalMalformed, &IdempotencyKeyError{…})`.
         - No `s.idem`, an empty `p.AccountID`, or an `s.disp` without `starter` gives `recordRefusal(RefusalInternal, ErrIdempotencyUnavailable)`.
       - **(b) Lookup comes next**, still before zero-match, Template refusal, cap and `checkApproval`: `s.idem.Lookup(ctx, p.AccountID, key, now-IdempotencyWindow)`.
         - A store error gives RefusalInternal.
         - A hit with a different digest gives `recordRefusal(RefusalMalformed, &IdempotencyConflictError{})`.
         - A hit with the same digest calls `resolveKeyedReattach`.
         - Because Lookup comes before `checkApproval`, a re-issue never spends a Grant use.
       - **(c) Claim at the claim point**, after `checkApproval` returns neither a park nor a re-attach:
         - Reserve a Job ID when `key != "" || (ap.grantID == "" && ap.requestID != "")`.
         - When there is a key, claim it **before** the approval `BindJob`: `s.idem.Claim(ctx, rec{AccountID, key, digest, jobID, now}, now-IdempotencyWindow)`.
         - A lost claim with the same digest goes to `resolveKeyedReattach`; a different digest is a conflict refusal.
         - A won claim registers `s.claims[k] = &keyClaim{jobID, make(chan struct{})}` **before** returning, so a concurrent re-issue always finds it. It then sets `d.jobID = jobID; reserved = true`.
         - Then `BindJob` as today:
           - Losing to `ErrAlreadyReleased`: `Rebind(compensateCtx, …, jobID, claimed.JobID)` (log on error), settle, then the existing AP-77 re-attach.
           - Any other BindJob failure: `Release(compensateCtx, …, jobID)` (log on error), settle, then the refusal.
           - Both compensations use the detached, bounded compensate ctx (S4).
       - A park claims no key, so an answered or re-driven retry claims it only when it actually dispatches.
  9. relay/internal/controlplane/execution.go — modify
     - Add `const MaxWaitSeconds = 300`. Its doc says: ADR-0004's "short wait budget"; it equals the daemon's per-Task hard wall clock; beyond it, poll `get_job`.
     - Add `func ResolveWaitSeconds(s int32) (time.Duration, error)`, which validates [0, MaxWaitSeconds] exactly like `ResolveTimeoutSeconds` and never clamps.
  10. relay/internal/controlplane/render.go — modify
     - Add `const JobStatusRunning = "running"` and `JobStatusComplete = "complete"`.
     - Add `func RenderJobState(ctx, results JobResultReader, jobID string) ([]RenderedTaskResult, string, error)`. The status is running if any Task's `State != jobs.DispatchStateResolved`.
     - `RenderJobResults` delegates and drops the status, so the Control API is unchanged.
  11. relay/internal/controlplane/mcp/wire.go — modify
     - Add to `RunOrPlanOutput`:
       - `JobStatus string json:"job_status,omitempty"`. Schema text: "running while any Task is unresolved (its outcome reads unspecified); complete once every Task has a terminal outcome; absent when no Job was created. Poll get_job with job_id until complete."
       - `Reattached bool json:"reattached,omitempty"`. Schema text: "true when this call dispatched nothing and returned the Job an earlier call created (same idempotency_key, or an approval already released)".
     - Add to `ExecutionOutput`: `JobStatus string json:"job_status"`, with the same text.
  12. relay/internal/controlplane/mcp/tools_execution.go — modify
     - `Executor`: replace `Run` with `Start(ctx, p capability.Principal, sel capability.Selector, act jobs.Action, o capability.StartOptions) (capability.StartedJob, error)`. Keep `CancelJob` and `Plan`, and update `execAdapter` to match.
     - Add to `runOperationInput` and `rawExecInput`:
       - `WaitSeconds int32 json:"wait_seconds,omitempty"`. Schema text: "0/omitted returns as soon as every Task is persisted, with job_id and job_status=running; poll get_job. 1–300 waits up to that many seconds for every Task to finish and returns full per-Node results if they did, otherwise the running Job. A lapsed wait never cancels the Job. Ignored with plan=true."
       - `IdempotencyKey string json:"idempotency_key,omitempty"`. Schema text: "Optional, ≤128 printable ASCII. A repeat of the same key by the same Account within 24h returns the existing Job and dispatches nothing; the same key with different arguments is refused. Send a fresh key per intended run, and re-send it unchanged when re-issuing after a lost response. Ignored with plan=true: a preflight claims no key."
       - Both fields use `omitempty`, so `callArgsDigest` is unchanged for calls that omit them.
     - `tightenExecInputSchema`: `wait_seconds` gets Minimum 0 and Maximum `controlplane.MaxWaitSeconds`; `idempotency_key` gets `MaxLength = capability.MaxIdempotencyKeyBytes`.
     - **Plan branch (S3):** it is unchanged and still calls `exec.Plan`, which has no key parameter. Add a one-line comment: "idempotency_key is deliberately ignored here: a preflight creates nothing, no dedup entry (PF-8)".
     - Add `type dispatchOpts struct { wait time.Duration; key string }`, built on the non-plan path only, after `ParseSelector`, via `controlplane.ResolveWaitSeconds`.
     - Rewrite `dispatch(ctx, exec, results, p, sel, act, o dispatchOpts)`:
       1. `sj, err := exec.Start(ctx, p, sel, act, capability.StartOptions{IdempotencyKey: o.key})`. The park branch is unchanged; other errors go through `renderRunError`.
       2. If `sj.JobID == ""`, return `Results: []`.
       3. If `o.wait > 0 && !sj.Reattached && sj.Done != nil`, select on `sj.Done`, a stopped-after-use timer of `o.wait`, and `ctx.Done()`.
       4. Call `controlplane.RenderJobState`. The adapter has no special not-found text: by construction, capability returns a keyed re-attach only for a persisted Job.
       5. Return `RunOrPlanOutput{JobID, Results, JobStatus, Reattached}`.
     - `renderRunError` passes these through verbatim: `*capability.IdempotencyConflictError`, `*IdempotencyKeyError`, `*IdempotencyPendingError`, `*IdempotencyReleasedError`, `*IdempotencyAbandonedError` and `capability.ErrIdempotencyUnavailable`.
     - `answerThenDispatch` gets an `o dispatchOpts` parameter, and both handlers pass the opts built from `in`. The `elicitedApprovedJobBound` branch is unchanged.
     - Descriptions:
       - `run_operation`: replace "Blocks until every matched Node's Task reaches a terminal outcome and returns per-Node results plus the Job id; get_job re-reads the same Job later." with the return-at-persist, `wait_seconds` (max 300) and `idempotency_key` contract.
       - `raw_exec`: add the same sentence.
       - `resultShapeClause`: add `job_status` and `reattached`, keeping every phrase `TestMCP_ResultShapeSemanticsAdvertisedInOutputSchema` asserts.
  13. relay/internal/controlplane/mcp/tools_jobs.go — modify
     - `get_job` renders through `RenderJobState` and sets `JobStatus`.
     - Its Description currently says the execution tools "already block and return full results". It becomes the polling path: "poll until job_status=complete".
  14. relay/internal/controlplane/mcp/mcp.go — modify comments only
     - The package doc and the `JobResultReader` doc say "Service.Run/CancelJob"; change them to Start.
  15. relay/cmd/relay/main.go — modify
     - Add `capability.WithIdempotency(st.IdempotencyKeys())` to `base`, next to `capability.WithMCPTasks(st.MCPTasks())` (~line 703), with a one-line rationale.
     - Production does not call Drain: main has no graceful-shutdown path today, and adding one is out of scope (see risks).
     - devharness.go is unchanged; it uses `Run`.
  16. Test fixtures — modify (B2)
     - **relay/internal/controlplane/mcp/mcp_test.go**, in `newTestServerWithConfig` and `newTestServerWithFailingReadSink`, and **toolevents_test.go**'s fixture:
       - Immediately after `capSvc := capability.New(...)`, and therefore after the `st.Close` cleanup and before the control server's Shutdown cleanup, register:
         `t.Cleanup(func(){ ctx, c := context.WithTimeout(context.Background(), 10*time.Second); defer c(); if err := capSvc.Drain(ctx); err != nil { t.Errorf("background Jobs did not drain before the store closed: %v", err) } })`
       - Cleanups run LIFO, so the order is: the test's own cleanups (for example closing `unblock`), then server Shutdown, then Drain, then store Close.
     - **relay/cmd/relay/relayfixture_test.go** `startRelay`: register the same Drain cleanup on `deps.capability` immediately after the `deps.store.Close` cleanup, so it runs after the node-plane stop and the control-plane Shutdown and before the store closes.
       - That is the only in-process fixture that mounts MCP: `serveControlPlane` is called only there.
       - The other `newServer` call sites (dispatch_test, newserver_test, labelscan_wiring_test, control_surfaces_test, revocation_persist_e2e_test) never call Start, so they need nothing.
     - In-package `staticVerifier` gains an `accountID string` field, returned as `Account.ID`, plus a fixture option to set it. The default stays "".
     - `fakeConduit` gains a per-Node dispatch counter.
  17. docs/STATUS.md — modify
     - Execution row (~line 35): MCP dispatch returns at persist, with `wait_seconds` for blocking; the idempotency key is implemented on MCP (`idempotency_key`) and not on the Control API.
  18. Docs walkthroughs — modify only where run_operation/raw_exec is promised to return inline per-Node results. Add "(pass `wait_seconds`, or poll `get_job` until `job_status` is `complete`)".
     - docs/dev-fleet.md
     - docs/operations/demo-fleet.md (e.g. ~981 "6 × success")
     - docs/operations/beta-hardware-validation.md steps 5–6 (lines 138–146; S9). Step 6's "verdicts matching the subsequent real run" compares against the Job's completed outcomes.
     - No edits to docs/specs, docs/adr, AGENTS.md or GLOSSARY.md.

Key decisions:
  - **The default changes and blocking is opt-in; the default wait is 0.**
    - This follows the issue's literal Target ("as soon as every Task is persisted"), per the coordinator's disposition.
    - ADR-0004 already describes the call as async with an optional `wait`.
  - **The `wait_seconds` ceiling is 300.**
    - It equals the per-Task hard wall clock, so a single-wave Job always fits.
    - ADR-0004 says "short", and a long hold is the #392 window.
    - The ceiling is documented in the schema, the descriptions and the `MaxWaitSeconds` doc.
  - **Pending, released and abandoned are decided in the capability layer** (B1, invariant 10). The adapter only renders typed errors.
  - **A re-issue is never told to use a new key while the original might still persist.**
    - A live in-process claim always gives Pending.
    - Only a claim with no live claimer, no Tasks, and age ≥ 10 minutes gives Abandoned.
    - After a crash, the 10-minute bound guarantees the dead claimer cannot still commit: persist is local SQLite under a 5s busy_timeout, and a restarted process holds no in-process claim for it.
  - **The bounded wait is 10s on an in-process signal, not polling.** The claimer is in the same process, because the Relay is single-instance. A concurrent re-issue during a millisecond persist simply re-attaches.
  - **Key validation follows Freeze and authorization** (S5).
    - ADR-0013 and Run's doc both require the Freeze check to be outermost.
    - A caller who may not dispatch learns nothing about keys.
    - Consequence: during a Freeze, a re-issue cannot recover its Job ID. This is fail closed and is flagged.
  - **The key lookup comes before zero-match, cap and approval.** A re-issue never spends a Grant use, and is not refused because the fleet changed since the original dispatch.
  - **The key is claimed AP-77-style**: with a reserved Job ID, before BindJob and before any Job row. CreateJob commits before RecordIntent, so a claim folded into RecordIntent could leave an empty Job.
  - **Compensating writes use a detached, bounded ctx** (S4). A dropped stream must not leave a key bound for 24h to a Job that never existed.
  - **The window is a fixed 24h constant.** An operator-set window would be config surface under invariant 11 that this issue does not need. Expired rows are swept inside Claim (S6).
  - **Timestamp encoding (S7): INTEGER Unix nanoseconds.** This is decided knowingly against the TEXT/RFC3339Nano precedent. Integer comparison is exact, and it avoids the #656 bug class (trimmed fractions sort wrongly). Nothing else reads this table.
  - **An invalid key and a key conflict reuse `RefusalMalformed`.** A new RefusalKind would add a `/metrics` series and churn the goldens. The doc comment is updated (S8), and the audit Reason carries the precise error.
  - **`plan: true` ignores the key entirely** (S3, PF-8). It is not validated, claimed, or looked up, because a preflight never reaches Start.
  - **Start is detected through an optional `starter` interface**, so `dispatcher`, the existing spies, and PR #954's hunks stay untouched. A key on a non-engine dispatcher fails closed.
  - **Async accounting failures are logged, not returned.** The caller is gone. The dispatch is never unrecorded, because the intent row commits with the Tasks (#948).

Test strategy:
  - **Churn rule (B2): every test that dispatches either waits or relies on the fixture drain.**
    - A test that waits passes `"wait_seconds": 300` or polls `get_job` to `complete`.
    - Any test that asserts the KindDispatch audit record, `IncDispatch` metrics, Job events, or per-Node outcomes **must wait**. These are now written after `Done`, and the adapter's wait selects on `Done`.
    - A test that asserts only `job_id`, a refusal, a park, or tool events may rely on the drain.
    - A test that blocks a Node must close `unblock` in a `t.Cleanup` it registers after the fixture, so the cleanup runs before Drain.
  - **Acceptance (a)** — `TestMCP_RunOperation_SilentNodeReturnsTheJobOncePersisted`. MCP seam, in a new file relay/internal/controlplane/mcp/mcp_async_test.go, so it does not collide with #954. Strengthened per S13:
    - Setup: `newTestServerWithConfig`. `node-a` is connected with `blockNode = node-a`, and `unblock` is closed only in cleanup, guarded by an atomic `released` flag. `node-b` is registered but never connected.
    - Call `run_operation(system.facts)` with no `wait_seconds` and a 5s client deadline.
    - Assert, at return:
      - `released == false`;
      - `job_id` is non-empty and `job_status == "running"`;
      - `st.Tasks().ByJob(job_id)` holds exactly one Task per Node, for both Nodes. This proves "returns once persisted", not merely "returns early".
    - Poll `get_job` with a bounded deadline until `node-b` is `unreachable` and `node-a`'s Task reads `DispatchStateDispatched`. Assert `node-a` is still `unspecified` and `job_status` is still `running`.
    - Companion `TestMCP_RunOperation_WaitSecondsReturnsFullResults`: a fast fleet with `wait_seconds: 5` gets per-Node results and `job_status == "complete"`, and the KindDispatch audit row is already present on return.
  - **Acceptance (b)** — `TestMCP_RunOperation_ReissueAfterDroppedResponseReturnsTheSameJob`. Same file; the fixture uses `accountID: "acct-test"`.
    - `node-b` blocks. The first call sends `idempotency_key: "k-1"` and `wait_seconds: 300` on a cancellable ctx.
    - Wait for `<-ready`, cancel the ctx (dropping the response), and wait for the call to error.
    - Re-issue with the same args on a new request.
    - Assert: it returns the same `job_id` as the single Job in the store; `reattached == true`; `node-b`'s dispatch count is 1.
    - Unblock, then poll `get_job` until it reads `complete` with `success`.
    - Sub-cases:
      - The same key with different args gives a tool error with the conflict text, and still one Job.
      - A 129-byte key is refused at the schema, and a key with a space is refused by capability; no Job in either case.
      - A key on the default fixture with no AccountID gives the `ErrIdempotencyUnavailable` text and no Job.
  - **S3** — `TestMCP_RunOperation_Plan_WithKeyClaimsNothing` (same file):
    - `plan: true` with `idempotency_key: "k-p"` returns verdicts.
    - `st.IdempotencyKeys().Lookup("acct-test", "k-p", epoch)` finds nothing, and no Job exists.
    - A later real run with `k-p` dispatches: `reattached` is false and the dispatch count is 1.
  - **Capability seam** — relay/internal/capability/start_test.go, a new file that reuses the in-package fakes without editing execution_test.go:
    - `TestStart_ReturnsOnceTasksArePersistedBeforeAnyResolves`
    - `TestStart_CallerCancellationDoesNotAbandonTheJob`
    - `TestStart_DoneClosesAfterTheDispatchAuditRecord`
    - `TestStart_DrainWaitsForBackgroundJobs` (a blocked conduit makes Drain return ctx.Err; after unblocking, it returns nil)
    - `TestStart_SameKeyReattachesAndDispatchesNothing` (asserts one `idempotent_reattach` read)
    - `TestStart_SameKeyDifferentArgumentsIsRefusedAndAudited`
    - `TestStart_ConcurrentSameKeyDispatchesExactlyOneJob` (N goroutines, `-race`)
    - **B1:** `TestStart_ReissueWhilePersistingWaitsThenReattaches`
      - An IntentRecorder wrapper blocks A inside RecordIntent, after the Claim.
      - B starts with the same key and has not returned after 100ms.
      - Releasing A makes B return `Reattached` with A's Job ID; Tasks exist; dispatch count is 1.
    - **B1:** `TestStart_ReissueWhileStuckPersistingIsPendingNeverNewKey`
      - Set `reattachPersistWait = 50ms` and keep A blocked.
      - B gets `*IdempotencyPendingError{A.JobID}`. Its message contains "get_job" and "same key", and does **not** contain "new key".
      - It is audited as `idempotent_pending`.
      - After A is released, a third re-issue re-attaches.
    - **B1:** `TestStart_UnpersistedClaimIsPendingUntilTheAbandonBound`
      - Pre-seed a row with no Job and no in-process claim.
      - Using WithClock, an age of 1 minute gives Pending, and an age of 11 minutes gives `*IdempotencyAbandonedError`, whose message contains "new key".
    - `TestStart_ReissueAfterTheOriginalFailedIsToldToRetryTheSameKey`: a failing IntentRecorder gives A an error, then B gets `IdempotencyReleasedError`, then C dispatches.
    - **S4:** `TestStart_KeyReleaseSurvivesCallerCancellation`: a repo wrapper cancels the caller ctx right after a won Claim, and the IntentRecorder fails. Assert the row is released and a re-issue dispatches.
    - **S5:** `TestStart_FrozenAndUnauthorizedRefuseBeforeAnyKeyValidationOrLookup`: an invalid key during a Freeze gives FrozenError, and the repo spy records zero calls.
    - `TestStart_KeyIsScopedToTheAccount`
    - `TestStart_KeyPastTheWindowDispatchesAfresh` (WithClock)
    - `TestStart_KeyWithoutAnAccountStoreOrEngineIsRefused`
    - `TestStart_ParkClaimsNoKey`
    - `TestStart_ReleasedApprovalReattachesAndRebindsTheKey` (AP-77 parity, lost BindJob)
    - `TestStart_ReissueDoesNotSpendAGrantUse`
    - The existing Run suites must pass unmodified, which proves the refactor.
  - **Store seam** — relay/internal/store/idempotency_test.go:
    - A second Claim returns the existing row.
    - **S6:** Claim sweeps expired rows, so the row count drops.
    - An expired row for the same key is replaced.
    - Lookup respects `notBefore` and the Account.
    - **S7:** two claims 500ms apart within one second order correctly at the window edge.
    - Rebind and Release are conditional on `job_id`.
    - The migration applies on a fresh DB and an existing one (the migrate_test pattern).
  - **jobs seam** — `TestRequestDigest_StableAcrossArgOrderAndDistinctAcrossFields`, in a new relay/internal/jobs/idempotency_test.go.
  - **Composition root** — `TestMCPExecution_ReissuedCallWithTheSameKeyReturnsTheSameJob`, in a new relay/cmd/relay/mcp_idempotency_e2e_test.go, proves `WithIdempotency` is wired in main. Run `make test-integration`.
  - **Schema** — extend:
    - `TestMCP_ExecutionToolsInputSchemaConstraints`: `wait_seconds` 0/300 and `idempotency_key` maxLength 128, on both tools.
    - `TestMCP_ResultShapeSemanticsAdvertisedInOutputSchema`: `job_status` phrases for run_operation, raw_exec and get_job.
  - **Existing tests**, under the churn rule above:
    - In-package mcp tests: mcp_exec_test, elicit_test, tools_legibility_test, toolevents_test, mcp_plan_test where it really dispatches, and preflight_surface_test.
    - cmd/relay e2e tests: answered_retry, approval*, elicit, mcp_elicitation*, mcp_execution, mcp_tasks, surface, template_dispatch, fleet, refusal_legibility, oauth_idp, reconnect_dedup, event_seam, tunnel_control, policy_immutability, query_read_floor and mrtr_harness.
    - Answered retries must send the same args map on the first call and the retry, so the sealed digest matches.
    - `TestMCP_CancelJob_InFlightAndPendingSibling` either passes `wait_seconds` or polls.
    - Test names cited in approval.md proof rows stay unchanged.
    - Each test keeps asserting what it asserted before; none is weakened to "job_id present".
  - **Test fakes implementing `mcp.Executor`** (goldenParker, askableParker, countingAskableParker, parkingExecutor, noopExecutor, scriptedRetryExecutor) add a `Start` returning `StartedJob{JobID, Done: closed}`. scriptedRetryExecutor serves its scripted results through its JobResultReader fake.
  - **Gates:** `opschema-check`, OpenAPI drift and `apiclient-check` are unaffected (nothing moves in /proto, opvocab, OpenAPI or the Control API). `make verify`, with race, before the PR.

Constraints and risks:
  - **Security surface (human review mandatory):** store (a migration and a new table), the capability dispatch path, MCP execution tools and the composition root. All are listed in docs/agents/security-surface.txt, so a security review must run.
  - **Invariant 4 holds by construction:**
    - Tasks are persisted Pending before any send, and fan-out runs once.
    - A keyed re-issue never reaches the dispatcher: it gets a re-attach, Pending, Released, Abandoned or a conflict.
    - A re-issue is never advised to mint a new key while the original could still persist (B1).
    - A re-issue **without** a key is a new dispatch, deliberately: the Relay never infers a repeat from content.
  - **Invariants 5 and 13 are unchanged.** Start shares Run's gates, and the adapter never touches risk class.
  - **Invariant 10:** the adapter forwards and presents. Dedup, pending/abandoned and the audit are decided in capability. No new mcp source file is added; tests are exempt from no_dispatch.
  - **Invariant 11:** no new operator config. The 24h window, the 10-minute abandon bound and the 300s ceiling are constants.
  - **Approval (binding):**
    - AP-60…AP-71: the park and question paths are unchanged.
    - AP-66: answered retries re-enter via Start.
    - AP-67 and AP-77: re-attaches are preserved, a park claims no key, and a lost BindJob rebinds the key.
    - AP-71 and ADR-0028: the shim is untouched.
  - **PF-8 (binding):** a preflight with a key writes no dedup row. This is tested.
  - **Background Job lifetime:**
    - Tracked by `Service.bg`, and drained in every MCP-mounting fixture ahead of the store close.
    - In production, `main` has no graceful shutdown, so a process exit mid-Job behaves as today's crash does: Dispatched Tasks are reconciled under daemon dedup, and Pending survivors never auto-run.
    - A Drain-on-shutdown hook in main is a follow-up, not this issue.
  - **Lost back-pressure (S12, for the security review):**
    - A blocking call used to tie up a connection per Job.
    - Start lets one Account start background Jobs without holding one, and MCP has no rate limit (`ratelimit` is enrollment-only).
    - The per-Job blast cap is unchanged.
    - This is raised as a capacity question in the PR body.
  - **Breaking change for MCP clients (Gleipnir, demo scripts):** the default result now carries `job_status: "running"` and `unspecified` outcomes until polled.
  - **The PR body must list (S11/S12; I may not edit these):**
    - (a) Idempotency is checked after Freeze and authorization, not first as execution.md says. A Freeze blocks Job-ID recovery.
    - (b) The window is a fixed 24h, not "operator-set".
    - (c) The default wait is 0, not "a short bounded wait", and the 300s ceiling. Both follow the issue's literal Target; the maintainer should confirm.
    - (d) An ADR-0004 amendment, or a new ADR, is **owed** for client-level idempotency (a new table plus a public MCP argument, hard to reverse). It is not a docs chore.
    - (e) "The existing MCP option" for the blocking form did not exist; `wait_seconds` and `idempotency_key` are new public MCP surface.
    - (f) GLOSSARY has no "idempotency key" entry, and lists "job handle" under `_Avoid_` while the issue title uses it.
    - (g) Lost back-pressure (S12).
    - (h) Acceptance (a) holds per Node: `unreachable` applies to the never-connected Node, and a silent connected Node stays Dispatched until restart reconciliation.
    - (i) "Part of #392", not "Closes": the Control API's `Idempotency-Key` and non-blocking return are unbuilt.
  - **PR #954 overlap (S10):**
    - execution.go changes only `run` (split into persist/fanOut/start) and adds `jobPersisted`; the header note, dispatchOne, cancel and withhold* are untouched.
    - relay/cmd/relay/fleet_e2e_test.go is on both PRs' lists, so expect a textual conflict. Keep #954's comment hunk when rebasing.
    - seams.go gets one doc-comment edit (S8). If #954 also touches seams.go, rebase that comment by hand.
  - **Migration number:** 0029 may collide with another in-flight PR; renumber at rebase if so.

Produced by the dev-loop. Must be merged by a human — nothing here merges autonomously.

🤖 Generated with Claude Code

https://claude.ai/code/session_017phewNxcFMxpU3ntX2TKek


Generated by Claude Code

claude added 5 commits October 9, 2026 01:28
The pull-request govulncheck gate now fails every Go PR on twelve newly
published standard-library advisories reached from the Relay's code:
GO-2026-6599/6600 (html/template), 6603/6605/6609/6610/6611/6612/
6613/6617 (net/http), 6607 (crypto/tls) and 6608 (net/textproto,
mime/multipart). Go 1.26.9 changes each of those packages (e.g. the
new 200-range cap on Range headers, GODEBUG httpservecontentmaxranges).

Every go directive moves 1.26.8 -> 1.26.9: relay/go.mod and the five
tools/* modules, so tools keep building with the module's toolchain.
No dependency changes; go.sum files are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017phewNxcFMxpU3ntX2TKek
(cherry picked from commit e9c94f3)
…ries

After the Go 1.26.9 bump the gate still reports GO-2026-6603, 6611,
6612 and 6617 (HTTP/2 server: Trailer memory exhaustion, repeated
initial-window changes, double flow-control refund, HPACK encoder race).
The Relay reaches them through google.golang.org/grpc's use of
golang.org/x/net/http2; x/net v0.60.0 changes exactly those server paths
(deferred SETTINGS_HEADER_TABLE_SIZE application to the encoder,
initial-window handling). Its go directive is 1.26.0, so nothing else
moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017phewNxcFMxpU3ntX2TKek
(cherry picked from commit bb84eab)
Upstream moved the v1 tag to e2a55d2 (dtolnay/rust-toolchain#187,
"Safely handle action inputs": multi-line-safe GITHUB_OUTPUT writes and
argv-array rustup flags instead of string-built command lines). zizmor's
online ref-version-mismatch audit now fails every PR's lint step because
our six SHA pins, commented '# v1', point at the old head 7e38f4b. The
new commit descends from the old pin and changes only action.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017phewNxcFMxpU3ntX2TKek
(cherry picked from commit 0a3a00a)
…re-issued call never double-dispatches

run_operation and raw_exec return the Job ID with job_status=running as soon as every Task is
persisted (capability Service.Start), with wait_seconds (0-300) as the opt-in blocking form; an
Account-scoped idempotency_key answers a re-issued call with the existing Job and never
dispatches again. No spec claims promoted (execution.md is legacy).

Closes #895

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017phewNxcFMxpU3ntX2TKek
@rapp992 rapp992 added the loop:needs-human dev-loop: escalated — human must review/merge label Oct 9, 2026 — with Claude
@rapp992
rapp992 merged commit d9f26f7 into main Oct 9, 2026
16 checks passed
@rapp992
rapp992 deleted the loop/issue-895-mcp-job-handle branch October 9, 2026 06:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

loop:needs-human dev-loop: escalated — human must review/merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: dispatch returns a Job handle once Tasks are persisted; a re-issued call never double-dispatches

2 participants