Repository navigation
fix(orchestration): a child that is not ready YET keeps its prompt - #1096
Conversation
Closes #854. `create_agent` delivered the bootstrap prompt immediately and returned `prompt_delivery_failed` if the child was not ready for one. The run journal says what "not ready" actually was, across 11 app runs and 55 recorded bootstrap failures: 26 prompt input is blocked by claude.trust-dialog 21 prompt input is still warming (composer-unpainted) 5 Codex: was not ready for prompt delivery (timeout) 3 did not record prompt acceptance …only 5 of the 55 ever reached the absorption stage. So 47 of 55 were states that clear on their own: a human answering the first-launch trust dialog that an orchestration child in a fresh worktree hits every time, or a TUI finishing its first paint. The child was fine and the prompt was early. That also settles the issue's second hypothesis, which could not be separated from the first at the time: paste folding is NOT what this was. `pasteConfirm` already counts `[Pasted text #N]` placeholders as absorption evidence, and absorption is where 5 of 55 failures got to. The reply made it worse. `disposition: retry-same-session` invited an immediate retry into the same not-ready window, and it is that second attempt that writes prompt bytes without Enter — the orphaned draft the parent can only escape by closing the child. Now the prompt waits for the composer. `SessionManager.deliverPromptWhenReady` re-arms the existing event-driven readiness gate until it opens, then delivers through the same path as any other prompt. `create_agent` returns `ok: true` with `promptPending: true` and tells the caller not to send it again. WHY there is no wall-clock deadline: the two dominant states clear on human action and on process progress, neither of which has a useful upper bound — a timer here would be a number invented to look decisive. The bound is the session: the wait ends when the prompt lands, when the session is retired (the cancellation is wired into `cleanupSessionState`, the one place per-session state is torn down), or when a direct delivery supersedes it. A second waiter is refused, because two would both fire and the child would get its brief twice. The line the fix does not cross: a failure about an attempt that actually happened — bytes written, an absorption or acceptance failure, a provider saying the session is unusable — still fails loudly and still cleans up the child where it used to. Waiting after bytes were written would risk a second copy of the prompt, which is the orphaned-draft half of this issue rather than a fix for it. The re-arm is paced: a gate that answers "not ready" instantly would otherwise make this a hot loop for as long as a child sits behind a trust dialog. Mutations, all killed (the suite hangs on the last one, which is its own kind of failure): no pending branch 4 failed; wait even after bytes were written 1; mark delivered whatever the outcome 1; never mark it 1; wait on a different prompt 2; session-unusable also waits 1; pending survives the session ending 1; a direct delivery does not supersede 1; a second waiter allowed 1; no re-arm pacing (hot loop) hangs. Scope: `send_prompt`'s own not-ready failures (38 Codex timeouts in the same corpus) are left alone. This issue is the create path, and a queued follow-up prompt raises ordering questions a bootstrap does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings on #1096. The evidence was right and the implementation generalised it from two providers to four. 1. Only Claude and Codex implement `awaitReadyForPrompt`. OpenCode and Grok report not-readiness as an ordinary `before-write` failure, so the "wait" for them was ONE immediate retry into the identical window — measured at 1 ms and one attempt — after which the prompt was gone while the reply told the parent not to send it again. That is a strict regression for the two providers that never had the orphaned-draft problem the suppression exists for, and the justifying corpus contains zero rows from either of them. `canWaitForPromptReadiness` now answers that question BEFORE the reply promises anything, and a gateless session gets the old honest failure back. 2. Racing one long-lived cancellation promise on every loop iteration retained a reaction per arm: ~969 bytes surviving a forced GC, about 40 MB a day per waiting child, unbounded — in a feature whose dominant case is a human answering a dialog in their own time. The wake is re-created per iteration now, and the comment that claimed "one waiter and nothing else" says what it actually costs. 3. `deliverPromptToAgent` cancelled the waiter from EVERY caller, and it has seven: a human typing in the child's pane, the phone, the goal loop, two compaction paths and the orchestration send. A child whose composer held a human draft parked the waiter indefinitely, so the moment that human pressed Enter the brief was thrown away silently. Only the orchestration send supersedes now — it is the one caller sending the same thing. 4. `PENDING_PROMPT_GATE_WAIT_MS` was documented as a cancellation checkpoint; cancellation is immediate (measured at 4 ms) and there is no Dispatch surface for this state at all. Its real job is pacing, and that pacing is load-bearing for the PRIMARY case rather than defence against a future provider: Claude answers `blocked` synchronously, and `blocked` is 26 of the 47 recorded cases. 5. The waiter map was deleted by session id, so a waiter that armed while another was delivering had its entry removed by that other one's teardown — unreachable afterwards, and free to deliver a second brief. Deletion is by identity now, with a test that parks a second waiter in exactly that window. 6. `isNotReadyYet`'s byte check is unreachable today; the comment credited it with the protection that the stage check actually provides. It stays as defence in depth, said plainly. Mutations, all killed: promise a wait without a gate 1 failed; canWait always true 1; a gateless wait retries once 1; every delivery supersedes again 1; the orchestration send stops superseding 1; delete the waiter by id 1. Method note: two of those looked DEAD in a batched run and alive when run individually. The batch harness was wrong, not the code — every number above was re-measured one mutation at a time against a verified-clean tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Finding 1 is the one that matters most and it is entirely fair: I drew a conclusion from a corpus containing only Claude and Codex rows and applied it to four providers, and for two of them the result is strictly worse than what it replaced. Fixed in 1. [HIGH] OpenCode and Grok lose the brief while being told not to resend. Accepted. Your PROBE D is unanswerable:
I did not give OpenCode and Grok a real wait: that is a provider-runtime change with its own evidence requirement, and the corpus has nothing to justify its shape. The narrow fix restores the behaviour they had. 2. [HIGH] ~40 MB/day per waiting child. Accepted, and this is the finding I would never have caught by reading — a 3. [HIGH] Any delivery ate the brief. Accepted — the seven-caller table is the argument. A human typing in the child's pane is not sending the brief, and I did not add a parent-visible notification for a cancelled brief. 4. [MEDIUM] The constant's documented purpose. Fixed. Cancellation is immediate (your 4 ms), there is no Dispatch surface, and the real job is pacing — which your PROBE C shows is load-bearing for the primary case, since Claude returns 5. [MEDIUM] Delete-by-id. Fixed by identity, and it has a test now: the gate re-closes while the first delivery absorbs (which is what Claude's really does), a second waiter parks in that window, and the first one's teardown must leave it reachable. It fails on the old code. On 6. [MEDIUM] The dead byte check. Fixed as you framed it: the Mutations after these fixes, each run individually:
Worth recording: two of those first appeared dead in a batched harness and alive when run one at a time. The batch was wrong, not the code — so every number above was re-measured individually against a tree verified clean between runs. It is the same lesson as the mutation table itself: a result is a claim about the measurement as much as about the code.
|
…pending # Conflicts: # src/mcp/runtime/createBuiltInMcpServer.ts
…erge Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #854.
What the evidence says
The issue's two hypotheses could not be separated at the time, because the one attempt that worked changed both variables. They don't have to stay unseparated: the app records every one of these in the run journal. 134 delivery failures, 11 app runs, 2026-08-30 → 2026-09-20.
create_agentbootstrap failures — 55 total:claude.trust-dialog47 of 55 are "not ready YET" — a human answering the first-launch trust dialog that a child in a fresh worktree hits every time, or a TUI finishing its first paint. The child was fine; the prompt was early.
It was not paste folding. Only 5 of 55 reached the absorption stage at all, and
pasteConfirmalready counts[Pasted text #N]placeholders as absorption evidence. Hypothesis 2 is answered by the corpus.And the reply made it worse.
disposition: retry-same-sessioninvites an immediate retry into the same not-ready window — and that second attempt is the one that writes prompt bytes without Enter, leaving the orphaned draft the parent can only escape by closing the child. The fix removes the reason to retry.The change
SessionManager.deliverPromptWhenReadywaits on the existing event-driven readiness gate and delivers through the same path as any other prompt when it opens.create_agentreturns:{ "ok": true, "agent": {…}, "promptSubmitted": false, "promptPending": true, "promptPendingReason": "…blocked by claude.trust-dialog", "message": "…do not send it again; use orchestration_read_agent to see when it lands." }and the bootstrap is marked delivered only when it actually lands.
No wall-clock deadline, on purpose: the two dominant states clear on human action and on process progress, neither of which has a useful upper bound — a timer would be a number invented to look decisive. The bound is the session. The wait ends when the prompt lands, when the session is retired (wired into
cleanupSessionState, the one place per-session state is torn down), or when a direct delivery supersedes it. A second waiter is refused, because two would both fire when the gate opens.The line this does not cross: a failure about an attempt that actually happened — bytes written, absorption, acceptance, or a provider verdict of
session-unusable— still fails loudly and still cleans up the child where it used to. Waiting after bytes were written would risk a second copy of the prompt, which is the other half of this issue rather than a fix for it.The re-arm is paced, because a gate that answers "not ready" instantly (a capability-skewed session, a future provider) would otherwise make this a hot loop for as long as a child sits behind a trust dialog.
Tests
Two suites: the manager's waiting (real
SessionManager, structural sessions, the gate as the seam) and the tool's behaviour (real MCP server, real handler, realOrchestrationBridgeover an in-memory transport). The recorded failure shapes are used verbatim in the fields the handler branches on.An existing case,
preserves a healthy child when readiness merely needs more time, asserted the old failure reply. It now asserts the new one — preserving the child was right, telling the caller to retry was not.Mutations, all killed (the last hangs the suite, which is its own kind of failure):
session-unusablealso waitsThree of those (
pending survives the session ending,a direct delivery does not supersede,a second waiter is allowed) survived my first battery: the tests reached them only by luck of timing, and the in-flight reservation was incidentally covering one. They are pinned now through the real teardown path and with controlled timing.npx tsc -bclean;src/main+src/mcp219 files / 1835 tests pass. One pre-existing failure,workflows/control.system.test.ts, reproduces identically on an unrelated branch offmainand is green in CI.Scope:
send_prompt's own not-ready failures (38 Codex timeouts in the same corpus) are left alone. This issue is the create path, and a queued follow-up prompt raises ordering questions a bootstrap does not have.🤖 Generated with Claude Code