Skip to content

fix(orchestration): a child that is not ready YET keeps its prompt - #1096

Merged
Juliusolsson05 merged 3 commits into
mainfrom
fix/bootstrap-prompt-pending
Sep 20, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
fix/bootstrap-prompt-pending

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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_agent bootstrap failures — 55 total:

n provider what the delivery said
26 claude prompt input is blocked by claude.trust-dialog
21 claude prompt input is still warming (composer-unpainted)
5 codex was not ready for prompt delivery (timeout)
3 claude did not record prompt acceptance
2+2 claude absorption
1+1 codex blocked / terminal

47 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 pasteConfirm already counts [Pasted text #N] placeholders as absorption evidence. Hypothesis 2 is answered by the corpus.

And the reply made it worse. disposition: retry-same-session invites 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.deliverPromptWhenReady waits on the existing event-driven readiness gate and delivers through the same path as any other prompt when it opens. create_agent returns:

{ "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, real OrchestrationBridge over 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):

mutation result
no pending branch (the bug) 4 failed
wait even after bytes were written 1 failed
mark delivered whatever the outcome 1 failed
never mark it delivered 1 failed
wait on a different prompt 2 failed
session-unusable also waits 1 failed
pending survives the session ending 1 failed
a direct delivery does not supersede 1 failed
a second waiter is allowed 1 failed
no re-arm pacing suite hangs

Three 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 -b clean; src/main + src/mcp 219 files / 1835 tests pass. One pre-existing failure, workflows/control.system.test.ts, reproduces identically on an unrelated branch off main and 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

Juliusolsson05 and others added 2 commits September 20, 2026 11:30
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>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

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 9d90f11d.

1. [HIGH] OpenCode and Grok lose the brief while being told not to resend. Accepted. Your PROBE D is unanswerable: promptPending: true, one transport attempt, zero marks, and the "wait" measurable at 1 ms.

canWaitForPromptReadiness now answers the question before the reply promises anything, and a session without a gate gets the old honest retry-same-session failure back. deliverPromptWhenReady refuses rather than doing the one-shot retry, because reaching it means a capability skew and pretending to wait is exactly what caused this.

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 Promise.race reaction on a promise that never settles is retained, and I asserted the opposite twice in comments. The wake is re-created per iteration now (one slot, replaced, dropped at the end of each arm), and both comments say what it actually costs instead of claiming it costs nothing. Given this repo's OOM history you are right that it should not have shipped on an assertion.

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 occupied appears ten times in the corpus, so this was reachable. Only orchestration_send_prompt passes supersedesPendingPrompt: true; every other caller leaves a waiting brief alone, and there is now a test where a human's prompt lands first and the brief still arrives afterwards.

I did not add a parent-visible notification for a cancelled brief. read_agent already reports promptSubmitted, and inventing a second channel for it belongs with whatever surfaces pending state at all — which, as you found, is nothing today.

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 blocked synchronously and blocked is 26 of the 47. Both comments now say that, including that a reader who deletes the pacing burns a core.

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 sessionManager.ts:2989 — the stable-id-reuse list — I left pendingPromptDeliveries out of it deliberately, and the reasoning belongs on the record: a waiter belongs to the pane, not to the backend generation, and a child whose first backend failed still wants its brief when the retry comes up. If that turns out to be wrong it is a behaviour change with its own test, not a line added to a list.

6. [MEDIUM] The dead byte check. Fixed as you framed it: the stage check is the protection, the byte check is defence in depth and unreachable today, and the comment says so rather than crediting it.

Mutations after these fixes, each run individually:

mutation result
promise a wait without a gate (the regression) 1 failed
canWaitForPromptReadiness always true 1 failed
a gateless wait retries once instead of refusing 1 failed
every delivery supersedes again 1 failed
the orchestration send stops superseding 1 failed
delete the waiter by id, not identity 1 failed

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.

npx tsc -b clean; src/main + src/mcp 220 files / 1842 tests pass, with only the workflows/control.system.test.ts failure you independently confirmed on the merge base.

…pending

# Conflicts:
#	src/mcp/runtime/createBuiltInMcpServer.ts
@Juliusolsson05
Juliusolsson05 merged commit 806a26b into main Sep 20, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/bootstrap-prompt-pending branch September 20, 2026 22:58
Juliusolsson05 added a commit that referenced this pull request Sep 20, 2026
…erge

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(orchestration): Claude child prompt delivery fails on create and an immediate retry orphans a half-written prompt

1 participant