fix: stream generated MCP/event Flight bytes before render completion - #718
Conversation
🦋 Changeset detectedLatest commit: c89eef2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 43cc77af0c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Coordination from #681: the surviving retention primitive is #715 ( Unlike #699, #715 does touch
#715's |
e2b8db4 to
c89eef2
Compare
|
Ack on #715: this PR lands first (gate green on c89eef2 over |
Closes #686
What
Generated MCP and event-route Flight workers (
generatedRouteFlightWorkerSourceinsrc/build/entry-shell.ts) bufferedrenderAgentFlightwithnew Response(flight).arrayBuffer()and posted onecompletemessage. They now read the Flight stream and post each chunk over the existingchunk/end/errortransport the rendered CLI/script worker already speaks — no second renderer, mode, or queue.Consumers switch from a resolved promise to a live
ReadableStream:src/mcp-server-runtime.ts(createFlightWorkerHost) — pending render holds the stream controller;chunkenqueues,endcloses,errorerrors, worker exit/error fail every pending stream.src/adapters/hook-contract.ts(standalone hook wrapper) — same shape.src/dev/routes/route-invocation-production.ts— still acceptscomplete(whole render in one message) from Flight workers compiled before this PR, since the epoch store restores such artifacts across dev-server restarts until the project rebuilds.All three hosts (plus the production host, which already spoke
chunk) gained acancelhook on the stream: the dispatcher's Flight reconciler cancels the reader when its session closes, which with chunked delivery can precede the worker'send. Without the hook a lateend/chunkhit a closed controller and crashed the host process (ERR_INVALID_STATE, surfaced as "Connection closed" on the next stdio call). Cancel now drops the pending entry and tells the worker to cancel, so the render is released and nothing reaches a closed stream.Context, provider/state lifetime, byte order and flow control are unchanged: chunks are posted inside
runAgentRequest's scope and beforebindings.close(),postMessagepreserves order, and demand is still bounded bycreateFlightDemand+ the SSE route's drain pacing.Coordination with #681 / #699
Events still flow one at a time through
publishRenderand the dispatcher; this PR adds no buffer, so #699'sretainRenderEventbound applies unchanged whichever lands first (no overlapping hunks inroute-invocation-production.ts). Comment left on #681.Acceptance
tests/entry-shell.test.ts— worker source postschunkinside the request scope andendafterobserved-render-finish; noarrayBuffer/complete; digest updated.tests/route-invocation-dev-server.test.ts— through the Workbench production surface (/api/routes/invocations+/stream): gatedtool:status/livepublishes itsAgent.Progressfallback shell before the gate file exists, thenshell → replace → completewhose document equals the ungated control run; cancel while blocked yields exactly['shell']and acancelledfinal with nothing after the gate releases;tool:status/crashsettles with exactly onecompleteand the same status/outcome/document as the non-streamed run; the same for the compiledevent:tool/afterroute with manifest-selectedclaudehost and preflight (shell → replace → complete, document/result/trace equal to the completed run).tests/generated-route-server.test.ts— real generated stdio server:notifications/progressfor the fallback arrives before the gate file is written; the Decide and pin the thrown-route-error projection per surface instead of adopting error.tsx/loading.tsx file conventions (xref row 8) #492 thrown-route test now also proves the host survives a session-close-before-endrace.packages/workbench/tests/streaming-render.e2e.test.ts(new, added to the integration pool) — Playwright at 1440×900: Rendered pane shows thestreamingprogress node witharia-busy="true"while the gate is closed, thenstream completewith no fallback, equal to the already-open-gate control run. Verified to fail against the buffering worker (fallback never appears within 15 s) and pass after.tests/helpers/gated-routes.ts.Local gate (final SHA c89eef2, rebased onto
origin/main@ b805eec; machine load avg 90–120 from unrelated Rust builds)Docs:
website/docs/*/guide/authoring/mcp.mdxalready describes streaming Suspense fallbacks and per-shell progress notifications; this PR makes the generated workers match it, no wording change. Changeset:.changeset/686-stream-generated-flight.md.Deslop: Claude Fable 5.1, 3 edits (dropped
try { controller.error() } catch {}wrappers —error()is a no-op on a non-readable stream; removed a redundantonErroralias; extracted the duplicated gated fixture).Also fixed on the way: a rejected
progress.report()in the MCP host (session already completed/failed) used to be an unhandled rejection; it now cancels the worker render and settles a still-pending stream once. An already-aborted signal no longer registers a pending entry or an abort listener.Self-review
Reviewer: GPT-5.6 Sol Max (
generalPurpose; thechange-risk-reviewercould not run because the TraceDecay daemon is down), two passes overorigin/main...HEAD.Pass 1 findings and disposition:
arrayBuffer()previously held whole in the worker and then in a one-chunk stream;postMessagetransfers the buffer without copying; downstream decode is paced bycreateFlightDemandand the SSE route by socket drain. A credit protocol would be the second queue/mode Stream generated MCP/event Flight bytes before render completion #686 forbids.progress.report()rejection inmcp-server-runtime.ts(High) — fixed:Promise.resolve(report).catch(...)cancels the worker render and settles a still-pending stream once; a late rejection after settlement is dropped.completebreaks restored pre-PR epochs (Medium) — fixed:route-invocation-production.tsacceptscompleteagain with a comment naming the restored-epoch case.canonical.payload.toolInput?.gateis always undefined (payload fields are{ nativeKey, value }) (Low) — fixed: fixture readstoolInput?.value?.gate;releaseGatereturns the release instant and both the tool and the event stream assertcompletedAt >= releasedAt, so the fallback is proven to come from a blocked render, not a replay.mcp-server-runtime.tsand the hook wrapper: error the stream and return before registering.Pass 2 (after fixes): one finding — a failed progress report settled the stream without cancelling the worker render (Medium) — fixed by posting
cancelbeforesettle. Reviewer reported no other material regressions (terminal/abort/cancel races settle once, legacycompletepath safe, aborted early return does not touch the worker).