Files
orca/src
Brennan Benson 007b7c0d32 fix(claude): end a message Claude started but never confirmed, and keep Claude running while it holds one (#23898)
* fix(claude): settle a queued send the CLI withdrew from its own cancelled frame

Claude reports each uuid-stamped command's lifecycle (queued, started,
completed, cancelled). A send it withdraws from its queue gets `cancelled`
before the interrupt or cancel_async_message answer, so a lost or failed
answer no longer leaves that send pending: it settles as withdrawn, with the
same reason and words as the receipt path.

A command the CLI already started also ends `cancelled` when its turn is
interrupted or fails, so `cancelled` after `started` is not a withdrawal;
an echoed send has left the waiter lists and is never reached.

Tests replay real 2.1.280 captures, scrubbed.

* fix(claude): release a doubted send when the CLI reports its session idle

A Claude send whose write ended in doubt is recorded `unknown`, and a live
`unknown` reads as work still owed, so the chat showed Working until the
child exited. Claude sends `session_state_changed idle` only once its whole
queue has drained, so it can no longer be holding that send. The runtime now
routes that report to the host's existing release, the same one Codex's
thread-stopped report uses; it retires `unknown` only, never `pending`.

* fix(claude): keep a command's started mark when a redelivery re-emits queued; fixtures name msg_lifecycle_v1

* fix(claude): settle every terminal lifecycle state of a send the CLI never echoed

A send the CLI started, then cancelled before any echo, stayed pending: it may
already be in the conversation, so it is released as doubt (unknown, recovered),
never withdrawn and never re-sent. A late echo still accepts it.

The 2.1.280 schema has two more terminal states. `discarded` (the CLI ended its
session with the send still queued) settles as not delivered; `refused`
(declined before it queued) settles as not accepted by the provider. After
`started`, either one is doubt, as `cancelled` is.

The late-settlement path gains an `unknown` outcome, which the host records as
released doubt.

* fix(claude): release a send the CLI took but left unanswered when it goes idle

`session_state_changed idle` comes only once the CLI's queue has drained, so a
send it took that is still unanswered there got no echo and never will: a turn
that throws can leave `started` with no terminal state. Idle releases it as
doubt.

What proves the CLI took a send is its lifecycle frame. On a CLI that reports no
lifecycle, it is the send's place on stdin: one whose write finished before an
interrupt went out was read before the interrupt was, so the first idle after
that interrupt releases it too. A send armed ahead of the interrupt but written
after it is left alone, since the CLI may still run it.

* fix(native-chat): keep the idle sweep off a Claude child that holds a send

A Claude retrying a rate-limited request has taken the send but echoes nothing,
so no turn row exists yet and the sweep rested the child after the idle window,
turning the send into doubt. The adapter now reports whether the CLI holds a
send (lifecycle `queued` or `started`, not yet echoed or ended), derived from
the live waiters, and owed work counts it.

Nothing is stored: every held send leaves the live set on its echo, its
terminal lifecycle state, the CLI's idle, or the child's exit, so the hold ends
with the send.

* fix(claude): count only a started send at idle and as a held send

2.1.280's end-of-turn cleanup can report idle before it re-reads its queue, so
a send read in that window goes queued, idle, started. Releasing every taken
send at idle doubted that live send and dropped Working. Only a `started` send
is released at idle or keeps the child from the idle sweep; a `queued` one ends
by starting and echoing, by a terminal lifecycle frame, or with the child.

The stdin-order path for CLIs without lifecycle frames is removed: a doubted
send retired there disables content matching on CLIs that mint their own echo
ids, and no Orca failure called for it. Those CLIs keep the earlier behaviour.

Comments that said only a failed write or child exit ends a waiter, or that
idle comes only once the queue has drained, now say what ends one.

* docs(claude): say only what the CLI's lifecycle frames and idle actually prove

* fix(claude): hold the idle sweep while Claude has a send queued, not only started

The sweep rested a child whose CLI had queued a follow-up behind a turn, dropping
the send it had already taken. The hold now spans the CLI reporting it took the
send until its echo, a terminal lifecycle state, or the child's exit. The idle
release still covers only started sends: 2.1.280 can report idle before it
re-reads its queue.

* refactor(native-chat): give provider-proven late dispatch settlement its own module

* test(claude): pin a steer a Stop interrupts after it started as doubt, not withdrawn
2026-09-30 14:11:41 -07:00
..