Repository navigation
feat(mcp): dispatch returns a Job handle once Tasks are persisted; a re-issued call never double-dispatches - #957
Merged
Merged
Conversation
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)
…b-handle # Conflicts: # relay/go.mod
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #895
Part of #392 (the umbrella stays open: the Control API's
Idempotency-Keyand 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_operationandraw_execnow return the Job as soon as every Task is persisted Pending, withjob_idandjob_status: "running"; the caller pollsget_job(which now also reportsjob_status:running|complete). The fan-out and the post-dispatch accounting (thedispatchaudit record, metrics) finish in the background on a detached context.cancel_jobstill 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_secondsbuilds ADR-0004's "optionally blocking up to a shortwaitbudget".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):Service.Startreturns aStartedJob.Runis refactored intoprepareDispatch+accountand behaves exactly as before for the Control API and Console.Service.Drainis wired into every test fixture that mounts MCP.BindJoband before any Job exists (the AP-77 pattern). A lostBindJobrebinds the key to the winner's Job.Authorize, and before zero-match, the blast cap and approval. A sequential re-issue never spends a Grant use.0029_dispatch_idempotency_keys, a new table. It is Account-scoped, uses integer-nanosecond timestamps, and sweeps expired rows insideClaim.no_dispatchguard now treatsStartandDrainas write verbs, with fixtures (security review finding 1).plan: trueignoresidempotency_keyentirely. A preflight writes no dedup row (PF-8), and this is tested.job_status: "running"andunspecifiedoutcomes until polled. Passwait_secondsfor the old behaviour.docs/STATUS.md,docs/dev-fleet.md,docs/operations/demo-fleet.mdanddocs/operations/beta-hardware-validation.mdare updated accordingly.Acceptance:
TestMCP_RunOperation_SilentNodeReturnsTheJobOncePersisted. The call returns while node-a's dispatch is provably still blocked, and both Tasks are already persisted. The Job later reportsunreachablefor the never-connected node-b. Note: this holds per Node. A connected Node that never answers keeps its Task Dispatched (restart reconciliation;unacknowledgedis not built), so it does not itself resolve Unreachable.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 samejob_idwithreattached: true, one Job in the store and one dispatch to the Node. The composition-root twin isTestMCPExecution_ReissuedCallWithTheSameKeyReturnsTheSameJob.Branch contents not authored by this issue
main(test(transport): regression test — gRPC requests without :authority/Host get a 400 and the Relay stays up (GO-2026-6443) #952, merged), not from this PR. The branch first carried the two commits of the now-closed relay: Go 1.26.9 and golang.org/x/net v0.60.0 for twelve new advisories #953, then mergedorigin/main.relay/go.modtakes main's version, andgo mod tidyproduced no diff, so those commits are no-ops againstmain.ci: repin dtolnay/rust-toolchain to the current v1 (e2a55d2)is ported from ci: repin dtolnay/rust-toolchain to the current v1 (upstream input-handling fix) #956 (cherry-pick -x) because zizmor's online audit otherwise fails Docs & paths. It becomes a no-op once ci: repin dtolnay/rust-toolchain to the current v1 (upstream input-handling fix) #956 merges. This session could not verify the SHA's provenance upstream; the REST call to dtolnay/rust-toolchain was refused.Overlap with open PR #954 (#943, cancel re-check)
This PR stays orthogonal to #954. In
execution.goit changes onlyjobEngine.run, split intopersist/fanOut, and addsstart/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 inpersistand released whenfanOutreturns, so #954's send guard covers background Jobs too.Verification
make verifygreen:env -u GH_TOKEN RELAY_SMOKE_PORT=20895 GOLANGCI_LINT_CACHE=<worktree>/.dev-loop/golangci-cache make verify, exit 0, on the final tree.RequestDigest: fixed at the root, with nonolint.settle()closingsettledbefore removing the claim from the map, fixed, and covered by a regression test.Gates skipped:
check-crosslinksmilestone half:gh apiis unauthenticated here. CI runs it.test-vuln-gatelocally is the script self-test./protois untouched. MCP tool schemas changed, but nothing moved in opvocab, OpenAPI or the apiclient, andopschema-check,openapi-checkandapiclient-checkall passed.Reviews
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.no_dispatchguard lackedStart/Drain): fixed.Drainnot called in production): the doc comment is corrected, and the gap is recorded under Escalations.reason=not-installed; no model was selected, becausescripts/codex-review.shexits 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.Capability spec
docs/specs/is still true of the code after this changeSpec:
docs/specs/execution.md, Statusaspirational(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 onlywait_secondsadded) andpolicy-preflight.md(PF-2 and PF-8 preserved and tested). Divergences from execution.md's stated intent, for the spec author:Authorize, which is more restrictive. Consequence: during a Freeze, a re-issue cannot recover its Job ID.Security surface
/proto, Policy, execution, transport, mTLS, auth, blast caps, label authorityPaths 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,Runsplit intoprepareDispatch/account, the engine'srunsplit intopersist/fanOut/start, and theRefusalMalformeddoc comment.relay/internal/controlplane/{execution.go,render.go}andrelay/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: wirescapability.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.Invariants touched:
RequestDigestexcludes it.Escalations
Unresolved (needs a human; the coordinator should apply
loop:needs-humanto this PR):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):
wait_secondsandidempotency_keyare new public MCP surface, and the non-blocking default is a breaking contract change for existing clients.Drainis not called in production (cmd/relayhas no shutdown path). On process exit, a background Job's post-dispatchdispatchaudit 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.cancel_jobdoes; this is what the issue asks for.wait_secondsandidempotency_keyare part of an answered retry's sealed digest (AP-65): re-send the call unchanged. The schema text says so.Implementation plan (approved after 2 plan-review cycles)
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