fix(mcp): negotiate per-turn lifecycle notifications - #1851
Merged
Conversation
wqymi
force-pushed
the
codex/mcp-turn-lifecycle
branch
2 times, most recently
from
July 27, 2026 07:55
f0219d9 to
782295e
Compare
Propagate one stable turn context through MCP tool execution and notify negotiated servers exactly once when a turn completes, is cancelled, or fails. Keep the behavior capability-gated and provider-neutral, with serialization and recovery tests for overlapping notifications.
Advertise the exact lifecycle v1 client capability and propagate turn cancellation into in-flight MCP calls so terminal notifications cannot race active tool work.
main's request-scoped MCP discovery gates MCP tools behind mcp_tool_search for the default test model, so mcp_lifecycle never executed and no turn context was captured. Pin the two tool-calling lifecycle tests to the non-GPT model that still exposes MCP tools directly.
…ends turnID/turnActorID on the tool Context were residue of the MCP-in-tool_script path that main removed in 8c29041; nothing reads them, so delete them. A lifecycle notification that never settles kept its pending-map entry forever, so every later turn for that client queued behind it, waited out the 1s timeout and was dropped -- a permanent, invisible per-turn stall. Record when a send started and release an entry that has outlived the budget so the next turn sends immediately, without ever awaiting the orphaned promise.
wqymi
force-pushed
the
codex/mcp-turn-lifecycle
branch
from
July 27, 2026 14:40
782295e to
7b48703
Compare
wqymi
added a commit
that referenced
this pull request
Jul 29, 2026
…rn-lifecycle The empty-shell guard deletes a contentless errored assistant row, so "how did this turn end?" can no longer be answered by re-reading the session. runLoop's tail already handles that via `droppedShell`, but `firePostSession` is a SECOND, independent reader of the turn's final assistant — it re-derives it from the persisted slice with filterCompactedEffect + findLast(role === "assistant"). With the row gone, findLast skips the hole to an older successful assistant (or finds none), so `finalIsError` went false and an `llm.error(400)` turn reported outcome "completed". That silently mis-reported the `session.post` plugin outcome and, since #1851 (merged 7167c47) derives the MCP turn-lifecycle `status` from the same two flags, published `status: "completed"` for a failed turn — breaking "MCP lifecycle emits one error notification when the outer run fails". Consult `droppedShell` first here too, mirroring the precedence runLoop already uses for `final`. Parts are empty because the row is gone.
This was referenced Jul 29, 2026
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.
Summary
Review follow-ups
Tool.Context.turnID/turnActorIDwereresidue of the MCP-in-
tool_scriptpath thatmainremoved in8c29041a8(request-scoped tool discovery). Nothing reads them at this head, so both the
declaration and its population in
SessionPromptare deleted rather than left aslatent surface.
client.notification()that never settled kept its pending-map entry forever, so every subsequent turn for
that client queued behind it, waited out the full 1s timeout and was dropped — a
permanent, invisible per-turn stall with no signal. Each pending send now records
startedAt, and a send that has outlived the per-turn budget is released (with alog.warn) so the next turn sends immediately. The orphaned promise is never awaitedagain and its eventual rejection is still swallowed, so no unhandled rejection
escapes. Serialization for healthy sends is unchanged.
Verification
test/mcp/lifecycle.test.ts: 31/31 pass (30 pre-existing + 1 new regression testproving a turn after a permanently hung send is still attempted promptly)
bun typecheck: exit 0, 12/12 tasksIndependent re-verification (follow-up pass, no code change)
Both review follow-ups above were re-checked from scratch against this head; no further
change was needed, so this branch is unchanged at
7b487037b.packages/plugin/src/tool.ts:4-21(ToolContext: sessionID, messageID, agent, directory,worktree, abort, metadata, ask) —
turnID/turnActorIDwere never declared on it. They werenonetheless runtime-visible to plugin tools, because
src/tool/registry.ts:184spreads...toolCtxintopluginCtxbefore callingdef.executeat:189. Reachable only through anas anycast against an undeclared field, that is incidental spread leakage rather than an APIsurface — documenting it would have enshrined an accident — so deletion remains the right call.
src/mcp/index.ts(3 insertions,37 deletions) while keeping the new test reproduces the bug exactly:
expect(stuckState.notificationCalls).toBe(2)→Expected: 2 / Received: 1attest/mcp/lifecycle.test.ts:739— the turn after the hung send is never attempted. Restoring thefix returns 1 pass / 12 expects.
WeakMapkeyed by client instance(
src/mcp/index.ts:94) with identity checks before every delete (:126,:147), and all fourhealthy-path
notificationMaxInFlight === 1assertions are unchanged and passing.test/mcp/51 pass / 0 fail (172 expects, 6 files) ·test/mcp/lifecycle.test.ts31 pass / 0 fail (102 expects) ·
bun typecheckexit 0, 12/12..liveprompt-effectMCP lifecyclecases have hard-coded 3s/5sbudgets and flake on a loaded machine. Across four runs at load 23-27 the failing member varied
({cancelled, hangs} → {cancelled} → {cancelled}), and with the fix reverted a different member
failed ({hangs}) — a non-deterministic set, i.e. scheduler starvation, not a regression.
rather than eagerly on the timeout path, to avoid racing the outer
Effect.timeout(TURN_LIFECYCLE_NOTIFICATION_TIMEOUT)at the same duration. A turn arriving insidethe first 1s after a hung send can therefore still pay one timeout and be dropped; every later turn
releases the orphan and sends immediately. The cumulative head-of-line block is gone; what remains
is ordinary single-notification best-effort loss.