fix(mcp): persist handoff results durably to survive transport timeouts - #453
fix(mcp): persist handoff results durably to survive transport timeouts#453anilkmr-a2z wants to merge 5 commits into
Conversation
handoff was fully synchronous with no persistence between result
extraction and HTTP response - a client-side transport timeout
silently dropped the result with no retrieval path. Adds a durable
handoff_results store, a structured "pending" response with job_id
on timeout, and a GET /handoff-results/{job_id} retrieval endpoint.
Fixes awslabs#447
|
Sweet. @anilkmr-a2z thanks for picking this issue. Looks a clean apprach to me. Feel free to assign to me for review |
d81fcbe to
6e1fb47
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #453 +/- ##
=======================================
Coverage ? 89.40%
=======================================
Files ? 157
Lines ? 18755
Branches ? 0
=======================================
Hits ? 16768
Misses ? 1987
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
CI's Code Quality job flagged 4 files for formatting drift after the main-rebase conflict resolution. No behavioral change.
|
Thanks for the offer! Just rebased onto main and fixed a formatting nit - CI's green now. Still marked draft while I do final validation; will flip to ready for review and add you shortly. |
call-me-ram
left a comment
There was a problem hiding this comment.
The persistence substrate here is solid — schema on the existing DB (inheriting the 0700/0600 posture from the #372 follow-up), correct retention wiring into cleanup_service, honest handler-branch coverage (running→completed/error incl. ValueError and generic Exception), job_id format validation. But as it stands it doesn't fix the failure mode #447 actually reports, so requesting changes on four points:
1. [must-fix] The job_id never reaches the supervisor in the primary scenario. pending=True/job_id are returned only from the except requests.Timeout branch (server.py:756–766), which fires after the internal HTTP deadline (timeout + 180s). #447's scenario is the opposite ordering: the provider's tools/call deadline (e.g. Codex's 600s) expires while _handoff_impl is still blocked in requests.post (e.g. 1080s for timeout=900) — the tool's return value, the only carrier of job_id, is serialized to a dead transport. The id is minted per-call (uuid4().hex), never logged, and there's no list/lookup-by-terminal endpoint, so the durably persisted row is undiscoverable in exactly the case the PR targets. Fix directions (any one): emit the job_id before the blocking wait (MCP progress/log notification), accept a caller-supplied idempotency key, add a recent-results listing, or soft-return a structured running result before the provider transport deadline (the issue's own "desired behavior").
2. [must-fix] Persistence happens AFTER teardown; the PR body claims the opposite. "Persists handoff results … before terminal teardown" — in fact run_agent_step extracts at agent_step.py:221 and tears down at :229–252 before returning; the upsert_handoff_result(job_id, "completed", ...) runs only after that in the endpoint's success branch. A crash in between loses the result — violating #447's "persist result, then tear down." Either plumb job_id into run_agent_step and persist between extract and teardown, or correct the claim.
3. [should-fix] No usable retrieval path for the supervisor. No MCP tool wraps the new endpoint, and the pending message says GET /handoff-results/{job_id} with no base URL — the supervisor LLM must guess host/port, be allowed to curl, and (if auth is on) hold a bearer token it has no way to obtain. A get_handoff_result MCP tool closes this and gives finding 1's polling half a home.
4. [should-fix] The new GET is unauthenticated while serving full worker output. get_handoff_result_endpoint (main.py:2183) has no require_any_scope dependency; auth here is per-endpoint, so when auth is enabled last_message (potentially prompts/secrets) is anonymous-readable. The 128-bit random job_id is a real capability mitigant, but newer sensitive GETs follow the gated posture — please add SCOPE_READ.
Smaller items, fine as follow-ups: the author-acknowledged same-job_id double-execute race (a re-POST with state="running" should attach/reject, not launch a second worker — and note the client can never reuse the key anyway today, which is finding 1 again); _migrate_add_handoff_results is redundant (create_all already creates missing tables — the raw-SQL duplicate can drift); blocking sqlite calls from async handlers (consistent with existing debt, but to_thread precedent exists); no startup reconciliation for stuck running rows (bounded by the 14-day purge, so minor). Test gaps worth closing with the rework: a true persist-then-fetch integration test (the GET tests mock get_handoff_result), and an auth test on the new endpoint.
Happy to re-review quickly — the substrate is right, it just needs the discovery/ordering half of the contract.
…trieval tool, auth Addresses 3 of 4 findings from call-me-ram's CHANGES_REQUESTED review: - Persist the completed handoff result BETWEEN extraction and teardown inside run_agent_step, not after it returns in the HTTP handler. The PR's prior placement ran after the terminal (the only other copy of the result) had already been torn down, contradicting the "persist result, then tear down" requirement from issue awslabs#447. - Add a get_handoff_result MCP tool wrapping GET /handoff-results/{job_id} so a supervisor LLM has an actual callable path to poll a pending result, instead of a bare HTTP path with no base URL or auth handling. - Scope-gate GET /handoff-results/{job_id} with require_any_scope, matching the posture of other content-serving GETs (/events, /memory/export). No-op when auth is disabled (the default). Finding 1 (job_id never reaching the caller in the primary awslabs#447 scenario — an external MCP client timeout racing ahead of the internal HTTP call) is a genuine architectural fork with several valid directions; replying on the PR to discuss rather than picking one unilaterally.
|
Pushed 430a210 addressing findings 2-4: Finding 2 (persist before teardown): moved the completed-state persist inside Finding 3 (no MCP-callable retrieval path): added a Finding 4 (unauthenticated GET): added Finding 1 (job_id never reaching the caller in the actual #447 scenario): confirmed this is real - traced through I don't want to pick one of your four suggested directions unilaterally since they're real architecture forks, not a bugfix:
Which direction do you think fits best, or would you rather this land as a separate follow-up PR scoped explicitly to that ordering problem, given the durability substrate here is now correctly independent of it? |
Merging upstream main pulled in awslabs#423's working-directory-inheritance change, which widened _patch_terminal_layer()'s return tuple from 7 to 8 values (added get_wd). The 3 tests added for issue awslabs#447 finding 2 still unpacked 7, breaking on the PR's merge-preview CI run.
New /graph view places each PR in context so the operator can judge roadmap/vision fit — is a PR a follow-up, does it build on prior merged code, or is it net-new? - build_pr_graph.py/.sh: fetches open + recent merged/closed PRs (one gh list call + parallel per-PR file fetches), derives typed edges — reference (#N in title/body with kind: fixes/closes/refs/followup/supersedes/partof), issue links, and idf-weighted file-overlap (ubiquitous files like README count little; co-touched rare files count a lot, avoiding a hairball). Classifies each open PR follow-up/related/builds-on/new. Filters bogus '#12345' refs beyond the repo's max number. Writes pr-review-data/graph.json. - server.py: /api/graph (serves graph.json) + /graph — a self-contained force-directed SVG graph (no CDN): drag/zoom/pan, nodes colored by state + open-PR classification (issues as diamonds), edges styled by relationship type, edge/node toggles, hide- isolated, search highlight, hover tooltip, click-through to GitHub. Link added to the main dashboard topbar. Validated on the live repo: awslabs#438->awslabs#395 (follow-up), awslabs#453->issue awslabs#447 (fixes), and provider/file-overlap clusters render correctly (150 PRs + 48 issues + 588 edges).
|
@anilkmr-a2z thanks for the thorough turnaround — findings 2–4 look good: persisting inside On finding #1 (the Why option 2 over the others:
Two things to fold into the follow-up when you do option 2:
So: happy for this to merge once the substrate is finalized, with two housekeeping items —
Thanks again — nice work here, and thanks @call-me-ram for the sharp review. |
call-me-ram
left a comment
There was a problem hiding this comment.
Re-reviewed at 8b85f58. Three of my four findings are properly fixed — thanks for the thorough round.
Finding 2 (persist-before-teardown) — fixed. run_agent_step now takes job_id and persists between extraction and teardown (agent_step.py:449-464), with the ordering documented at the call site and in the docstring (steps 6/7). This is what #447 asked for, and the claim in the PR body is now true.
Finding 3 (retrieval path) — fixed. The get_handoff_result MCP tool (server.py:1335+) gives the supervisor a callable path instead of a bare URL it would have had to guess a host, port and token for.
Finding 4 (auth on the GET) — fixed. get_handoff_result_endpoint now carries require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN), matching the posture of the other sensitive GETs.
Finding 1 is still open, and it is the one that decides whether this PR fixes #447.
Minting job_id client-side before the POST (server.py:725) and sending it in the payload is a real improvement — the server can now record the row under a key the client already knows. But the id still only ever reaches the supervisor through one carrier: the HandoffResult returned from the except requests.Timeout branch (server.py:766-776).
#447's reported ordering is the other one:
- MCP client timeout:
client_timeout = timeout + _CLIENT_TIMEOUT_HEADROOM— e.g.900 + 180 = 1080s - Provider
tools/calldeadline: e.g. Codex's600s
The provider transport dies at 600s while _handoff_impl is still parked in requests.post. The requests.Timeout branch never runs, so nothing is returned, so the job_id is never observed by anyone. I re-checked the two ways it might still be recoverable, and neither exists at this head:
- no listing/lookup endpoint (
GET /handoff-results/{job_id}is by-id only; no by-terminal, by-session or recent-results route) job_idis logged only on failure paths (main.py:1906, 1959, 1971, 1982, 1990— alllogger.warningon persist failure), never on the success path
So the durable row exists and is correct, and remains undiscoverable in precisely the scenario the PR targets. Everything downstream of discovery is now built and working, which is why I would like to see this last piece rather than land it half-connected.
Any one of these closes it:
- emit the
job_idbefore the blocking wait (MCP progress/log notification), or - accept a caller-supplied idempotency key so the supervisor already holds it, or
- log the
job_idunconditionally at INFO on the success path and add a by-terminal/recent listing, or - soft-return a structured
runningresult before the provider transport deadline (issue #447's own stated "desired behavior")
The last is the most faithful to the issue and also removes the double-execute race you documented at server.py:722-724, since a caller who holds the key up front can be made idempotent.
Also needs a rebase — the PR is currently DIRTY (conflicts with main); ac07018 merged upstream/main on 07-19 but main has moved since.
Re-review will be quick once finding 1 has a carrier — the substrate, ordering, retrieval tool and auth are all in good shape now.
gutosantos82
left a comment
There was a problem hiding this comment.
PR Review: #453 — fix(mcp): persist handoff results durably to survive transport timeouts (re-review)
Context
Head 8b85f586, MERGEABLE, all 11 CI checks green (unit 3.10/3.11/3.12, Code
Quality, Security Scan, Trivy, Dependency Review, MCP Apps, Web UI). 10 files,
+984/−5. reviewDecision=CHANGES_REQUESTED — external reviewer call-me-ram
requested changes at the previous head (2026-07-17) on four findings. The
author (anilkmr-a2z) pushed 430a2104 ("address PR #453 review") + a merge of
upstream/main + a test fix (8b85f586), and replied point-by-point on the PR.
fanhongy has volunteered as a reviewer. Codecov: 86.4% patch coverage.
Delta since our previous report (at 3630081f): +224 net lines, all directly
responsive to the external review. Our previous verdict was also "Request
changes" on essentially the same finding-1 grounds plus the "Fixes #447"
linkage.
What the new commits fix (verified in the diff)
Finding 2 — persist-before-teardown: FIXED, correctly
The previous placement persisted state="completed" in the HTTP handler
after run_agent_step returned — i.e. after the terminal (the only other
copy of the result) was already torn down. Now:
run_agent_steptakes an optionaljob_idand performs the
upsert_handoff_result(job_id, "completed", ...)viaasyncio.to_thread
between extraction and teardown (agent_step.py), best-effort (logged,
never raised).- The handler's success branch no longer persists at all; a NOTE comment
explains why. Error-path persistence (all four exception branches) stays in
the handler, which is the right split since only the handler knows the
exception kind. - Tests assert the ordering (
["upsert", "delete"]observed sequence),
not merely that both calls happened; plus no-job_id no-op and
persistence-failure-doesn't-block-teardown cases. This is exactly what the
reviewer asked for. - Bonus: the sqlite write moved off the event loop (
to_thread), addressing
one of the reviewer's smaller items on this path.
Finding 3 — usable retrieval path: FIXED
New get_handoff_result MCP tool wrapping GET /handoff-results/{job_id}
(same pattern as delete_terminal), returning
success/state/terminal_id/last_message/error_message, with 404 and generic
failures mapped to structured success=False. The pending-timeout message now
says "Retrieve the result with the get_handoff_result tool, job_id=..."
instead of a bare HTTP path with no base URL/auth. Tests cover
completed/running/not-found/error branches and assert the message names the
tool and contains the job_id.
Finding 4 — unauthenticated GET: FIXED
get_handoff_result_endpoint now depends on
require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN), matching the
posture of /events and /memory/export; no-op when auth is disabled (the
default). Docstring documents the rationale.
What remains open
Finding 1 (must-fix) — job_id still never reaches the caller in the primary #447 scenario
Unchanged at this head: client_timeout = timeout + _CLIENT_TIMEOUT_HEADROOM
(180s), and pending=True/job_id are returned only from the
except requests.Timeout branch. When the provider's MCP tool-call deadline
(Codex's injected 600s) expires while _handoff_impl is still blocked in
requests.post (e.g. 1080s for timeout=900), the tool's return value — the
only carrier of the job_id — is serialized to a dead transport. The id is
minted per-call client-side, never logged, and there is no
list/lookup-by-terminal surface, so the durably persisted row is
undiscoverable in exactly the targeted case. The new get_handoff_result tool
gives the polling half a home but does not solve discovery: the caller still
has nothing to poll with.
The author's handling of this is exemplary rather than evasive: they traced
the timeout math, confirmed the finding is real, enumerated the four candidate
directions from the review (progress/log notification before blocking;
caller-supplied idempotency key; recent-results listing; soft-return a
structured running result before the provider deadline — noting the last
matches issue #447's own near-term suggestion but changes handoff's external
contract), and explicitly asked the reviewer to pick a direction or bless a
scoped follow-up PR. That question has been sitting unanswered since
2026-07-17 — the ball is in the reviewer's court, not the author's.
"Fixes #447" still overstates scope
The PR body still opens with "Fixes #447"; merging as-is auto-closes an issue
whose desired async lifecycle (dedup/idempotency, immediate id return,
completion hint, ack) remains substantially unimplemented. Should become
"Related to #447" / "Partially addresses #447" unless finding 1 is resolved
in-PR.
Minor residuals (non-blocking, mostly reviewer-acknowledged follow-ups)
- No dedicated auth test on the new GET and no true persist-then-fetch
integration test (the GET tests mockget_handoff_result) — the reviewer
listed these as gaps "worth closing with the rework"; still open. _migrate_add_handoff_resultsremains a raw-SQL duplicate of what
create_allalready does (drift risk, reviewer-flagged as follow-up).upsert_handoff_resultis still query-then-write (benign today: fresh uuid
per call); same-job_id double-execute race still documented-not-fixed
(inherent to finding 1's resolution).- Retention sweep still filters on
created_at(a >14-day-running job would
be purged mid-flight; theoretical).
Security assessment
Improved since last head: the new endpoint is now scope-gated when auth is
enabled, closing the anonymous-read exposure of last_message that the
external review flagged. Otherwise unchanged: localhost-only/Host-header
posture inherited, 128-bit strictly-validated job_id, no permission-surface
changes. Sensitive paths touched (mcp_server/server.py, api/main.py,
agent_step.py) — publish gating applies.
Verdict
Request changes — but note the standing CHANGES_REQUESTED from
call-me-ram already blocks merge, and the author has done everything asked
except the one item they were explicitly told had multiple valid directions
and have asked for guidance on. Posting a second formal request-changes adds
little; the highest-value human action is to answer the author's direction
question on finding 1 (pick one of the four fixes, or agree to a scoped
follow-up PR with the issue linkage softened to "Partially addresses #447").
If a comment is published from this report, it should say that. Not
merge-ready as-is solely because of the finding-1/issue-linkage pair; the
durability substrate itself is now correctly built, correctly ordered, gated,
and well-tested.
Fixes #447
Summary
handoffwas fully synchronous with no persistence between result extraction and the HTTP response, so a client-side transport timeout could silently drop the result with no way to retrieve it. This PR makes handoff results durable:pendingresponse with ajob_idinstead of dropping the result.GET /handoff-results/{job_id}endpoint to retrieve the persisted result once it's ready.Test plan
test/clients/— passingtest/api/— passingtest/mcp_server/— passingtest/services/— passingmainand reproducing the same 4 failures.