Repository navigation
Description
Expected behaviour
A prompt delivery that fails should leave the session able to accept the next prompt.
Actual behaviour
After one failed delivery, every subsequent submit is refused forever with prompt input is occupied by a human draft. The session never recovers on its own. Reported symptom: "on every prompt I submitted in the application it said that a human draft occupied it and it did not submit any prompt."
Root cause — the "human" is Agent Code itself
Three individually-defensible decisions compose into a deadlock:
deliverClaudePromptwrites the prompt body into Claude's PTY composer, then waits up to 2s (CONFIRM_TIMEOUT_MS) for Claude to visibly absorb it, detected by screen diff.- On timeout it returns
promptWritten: true, enterWritten: false,do-not-retry— deliberately leaving the bytes in the composer. Its own comment: "Prompt bytes are already editable in Claude even when our detector times out. Automatic retry is unsafe: it would append/duplicate those bytes." - Nothing clears them. There is no composer-clearing code path anywhere in the repo.
- The composer now genuinely holds text, so
parseClaudeComposerStatecorrectly returnsdrafted. derivePromptGateStatereturnsoccupied / human-draft, and that state is deliberately not time-bounded — a 10s bound was tried and removed because it destroyed real user input mid-sentence.- Every later delivery is refused at
before-writein ~32ms.
Nothing is wrong with any single step. The system writes bytes it cannot confirm, abandons them by design, then treats its own abandoned bytes as a human draft it must never overwrite — with no exit.
Evidence
From the lifecycle journal (incidents/runs/*/events.jsonl), the two-event signature:
20:19:40 ca7dbc9c absorption-timeout bodyWritten:TRUE enterWritten:FALSE 2017ms
20:19:59 ca7dbc9c not-ready before-write bodyWritten:false 32ms
Aggregates across all recorded runs:
submit.resultfailures: 88 —not-ready57,acceptance-timeout20,absorption-timeout10- deliveries that wrote the body but never sent Enter (orphaned writes): 10
gate.evalevents: 2,319 — of which 833 (36%) areoccupied / human-draft, every one aclaudesession- occupied duration: median 0.3s, but p90 27s, 56 entries >60s, 24 >300s, max 47h
- 19 of 216 sessions ended with their last gate state
occupied— never recovered
Why it was invisible
readiness.ts collapses every non-ready verdict to one string:
'provider-not-ready': 'waiting for agent'replay-pending, composer-unpainted and human-draft are indistinguishable on screen. Both readiness.ts and session-runtime/state.ts flag this as a known deliberate gap. A latched gate and a slow boot look identical, which is why this needed a full journal dig to identify.
Manual recovery today
Focus the agent's raw terminal view and press Ctrl+U to clear the line. The gate re-evaluates on the next frame and returns ready.
Acceptance criteria
- A delivery that writes the body but cannot send Enter must not leave the session permanently unable to accept prompts.
- Whatever the recovery, the prompt text must not be silently destroyed without the user being told.
- A latched
occupiedstate must be visible in the UI as something other than "waiting for agent". - Genuine human drafts must still block automated delivery — the protection this gate exists for cannot regress. The 10s-bound approach is out of scope: it was tried, and it destroys user input.
Related but separate
parseClaudeComposerState(screen, null) returns drafted for Claude's dim placeholder text (e.g. Try "how does CommandPalette.tsx work?"), because the string fallback fails closed. Reproduced against a real captured screen: identical screen returns empty when attrs are supplied, drafted when they are not. That accounts for the ~52% of human-draft events that fire nowhere near a submit. Filed separately if it is not fixed here.
Activity
fix(composer): never forward Tab to the provider and let Escape clear…