Repository navigation
fix(native-chat): every chat action press is its own action - #23916
brennanb2025 merged 24 commits into
Conversation
de73921 to
bd08f9e
Compare
09fa9e9 to
89564af
Compare
A client kept an operation id per method and payload across presses, so a later identical press replayed an earlier action instead of running: an option picked again stayed on the other one, a goal set again stayed cleared, and a second Stop or phone Stop did nothing. Every press now mints its own id, on desktop and phone, and no client keeps a retry id. The host answers a harmless repeat from what the chat records: the same prompt answer again returns the resolution it holds, and a Cancel of a prompt already cancelled answers ok. A second Stop of the same turn while the first is on its way joins it.
…th that clear Every press now carries its own operation id, so a /clear that reaches the host after this caller's /clear already committed (a double press, or a retype after a lost answer) no longer replays the first id. It started the cleared conversation's agent and then refused it with "This conversation has been cleared." The host now answers such a /clear from the committed record: the same caller, on a conversation whose tab moved to its replacement, gets that clear's result, replacement included, before admission starts anything. No second clear runs. Another window, or a cleared conversation reopened from history, still reads "cleared".
…e gates that ran it
|
Queued-card review for this PR, from the author of #23726/#23731/#23736. Line numbers are from main Send-now and Delete already decide by the draft's state, not by operation id. A second press with a fresh id is already answered quietly:
Where id reuse still matters: the send itself. This isn't a card action, but it's the one that creates a draft, and it depends on keeping its id across a retry.
Other id reuse in the cards. None: Steer, Delete, Edit and Resume don't reuse an id beyond what's listed here. The only other place is the Send-now retry after transport doubt, which is covered by the NEVER merge. Review only. |
…d by the draft's state
…stopping A client joins it itself only against a host older than this, which runs both and writes a false 'already finished' row for the second.
89564af to
7801b8f
Compare
…r adds no row Read from the earlier Stop's note on that turn in the journal, so it holds however late the second Stop lands and from whichever client, and across a host restart. Replaces the in-memory join. The capability now says the host answers a repeated Stop quietly; clients still join a Stop on its way only for a host without it.
|
Status: head Since the last status:
Live QA on a second Mac with real Claude, #23935 against this PR. Two Stops of the same turn under separate ids, at once and 1.5 s apart: before, a false "The provider had already finished this turn."; after, one "Cancellation requested.". That was confirmed from both arms' journals. The desktop's own double-clicked Stop is unchanged, with one row. Screenshots are in Visual Proof. Phone QA is BLOCKED: the emulator QA skill it needs can only be started by a person, so the phone checks were not run live. The 68 mobile tests cover them; the reason and the checks left are listed in the body. Test flake seen, not caused by this PR: |
|
| } | ||
| const { item, prompt } = settled | ||
| const choice = readPromptChoice(prompt, input) | ||
| return choice?.selectedOptionId === prompt.resolution.selectedOptionId |
There was a problem hiding this comment.
Changed answer falsely accepted
If a single-question prompt accepts free text and the first reply is lost, the user can submit an answer that differs only in surrounding whitespace. The recorded choice ID trims that whitespace, so this comparison returns success without sending the second answer to the provider. The user is told the new answer was accepted even though the provider received only the first one.
Knowledge Base Used: Agent workspaces and sessions
There was a problem hiding this comment.
Important
The repeated-Stop guarantee has a gap for command turns: a second Stop of a running /compact turn can still append a second "Cancellation requested." row, because the fallback at structured-agent-session-turns-cancel.ts:146-150 overwrites the note that answeredBefore had nulled. Detail inline.
Reviewed changes
- Fresh operation id per press. The desktop mutate hook and the mobile mutate/Stop/composer-command paths now mint a new client operation id on every press; the per-payload id map, mobile id-retention helper,
structured-agent-session-operation-identity.*, andisUnconfirmedConversationCommandare deleted. - Host answers harmless repeats from recorded state. A re-answered prompt returns its held resolution (
resolvedWithSameChoice); a Cancel of an already-cancelled prompt answers quietly; a/clearre-pressed by the same caller on a tab-less source short-circuits to the committed record before admission (committedClearOfCaller); a repeated Stop of a turn an earlier Stop answered for is suppressed by reading that Stop's note off the named turn (structuredAgentSessionNamedTurnStop). - New capability
agent-session.repeated-stop.v1plus a transitional client-side in-flight Stop join used only against hosts that lack it.
The /clear short-circuit was checked: context.openConversation is journal-only and starts no agent, and the tabId === null guard only ever returns the same already-committed fact. No other concerns.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
… the one it repeats A /compact from the same caller joins its last /compact while that one waits or runs, and is answered by it once it compacted with nothing sent since, read from the journal, so a retry after a lost reply never compacts twice. The same caller's second /clear waits for the first and is answered by the committed clear. Another caller or another command is still refused.
There was a problem hiding this comment.
Important
A repeated /compact press can be answered with a false completed when the command turn it joins was already stopped but has not ended yet. Detail inline.
Reviewed changes
- Changed the conversation-command in-flight join key to
[callerKey, command], so a same-caller repeated/clearwaits behind the first and is answered by it instead of being refusedconversationCommandInFlight. - Added
repeatedStructuredCompaction, which joins a/compactpress to the caller's last accepted/compactwhile that one waits, runs, or compacted with nothing newer, read from the journal. - Added host tests for the
/cleardouble press and the repeated/compact(including a relaunched host), plus a tab-table test for a/clearanswered after commit.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…red from the one it repeats" This reverts commit 25541b7.
…ething, keyed by the turn A Stop that cancelled nothing writes no row, and the note is keyed by the turn it stopped, so another Stop of that turn rewrites it. The earlier-note lookup goes. A /compact or /clear pressed again while one runs is refused as before, and one pressed after it ended runs.
| await ctx.journal.appendItem( | ||
| structuredAgentSessionStopNoteIdentity(input.clientOperationId), | ||
| structuredAgentSessionStopNoteIdentity(input.turnId ?? liveTurnId ?? input.clientOperationId), | ||
| note, | ||
| { fence: ctx.fence, turnScope } |
There was a problem hiding this comment.
Failed Stop replaces confirmed note
When a Stop succeeds, it writes “Cancellation requested.” If a later Stop of the same turn encounters an adapter error, this turn-keyed write replaces that confirmed note with “Cancellation was not confirmed.” The chat then shows a failure in place of the successful cancellation.
Knowledge Base Used: Agent workspaces and sessions
There was a problem hiding this comment.
ℹ️ No critical issues — one test-coverage suggestion inline.
Reviewed changes
Reviewed the three commits pushed since the prior pullfrog review (f5e0acee4c, 7e950635fd, 8867990f21); the rest of the PR was covered in earlier runs.
- Reverted the
/compactrepeat join.f5e0acee4creverts25541b7fc0: the conversation-command in-flight key returns to[callerKey, clientOperationId],structured-conversation-compaction-repeat.*is deleted, and a/compact//clearpressed again while one runs is refused by the existing gates rather than joined. This removes the previously open risk where a stopped-but-still-running compaction could be answered with a falsecompleted. - Stop notes keyed by the turn they stopped.
7e950635fddropsSTOP_NOTE_ALREADY_FINISHEDandstructuredAgentSessionNamedTurnStop, and keysstructuredAgentSessionStopNoteIdentitybyturnId ?? liveTurnId ?? clientOperationId. A Stop that cancelled nothing writes no row, and a second Stop of a runningcompact:turn rewrites the one turn-keyed row instead of appending a second — closing the command-turn duplicate reported in the prior review. - Capability comments.
8867990f21updatesagent-session.repeated-stop.v1's comments to the new meaning (comment-only).
I re-ran the touched host suites (92 tests across 10 files) plus pnpm tc:node; all pass, and no stale references to the removed symbols remain.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| it('adds one row for two Stops at once, whatever the provider says to the second', async () => { | ||
| await attach() | ||
| await turn('running') | ||
| const [first, second] = await Promise.all([stopTurn(), stopTurn()]) | ||
|
|
||
| expect(first).toMatchObject({ ok: true, value: { cancelled: true } }) | ||
| expect(second).toMatchObject({ ok: true }) | ||
| expect(await statusRows()).toEqual([REQUESTED]) | ||
| }) |
There was a problem hiding this comment.
The prior review's command-turn finding was fixed by keying the note on the turn, but this case only covers a normal turn-1. A command turn takes the runningCommand override that force-rewrites note to Cancellation requested. after stopChild, so a second named Stop of a live compact: turn is the exact path that used to append a duplicate row — and no test here asserts its row count. Consider a command-turn variant (two named Stops of a running compact: turn, expecting one row) so that path stays pinned.
…eated-Stop capability
…st-send' into brennanb2025/chat-op-id-per-action
…client checks the host The desktop's capability check before a named Stop could let the runtime move underneath, so the Stop went out against the new one; it now carries the fence read at the press.
Comments Outside DiffThese findings could not be posted inline.
|
There was a problem hiding this comment.
ℹ️ No critical issues — minor suggestions inline.
Reviewed changes
Reviewed the five commits pushed since the prior pullfrog review (37bc02032e, 4380ad85a9, 13a6333cd6, 65bde90440, 3e11a8f5e3); the two merges and 4380ad85a9 are mechanical.
- Captured the press-time fence for a named Stop.
writenow readsstateRef.current.fencebefore it awaits the quiet-repeated-Stop capability and threads it intosend, so a Stop can no longer go out against a runtime the pane replaced while that probe was in flight (65bde90440). - Covered both host capabilities in the fence-move test. The command-start-failure test now asserts the drop for hosts with and without
agent-session.repeated-stop.v1; removing the explicit pressed fence makes both cases fail (3e11a8f5e3,4380ad85a9). - Merged the base branch. Adapted test call sites to the base's two-argument
host.close; no behavior change.
ℹ️ Nitpicks
send's defaultpressedFence = stateRef.current.fenceis now unreachable — both call sites pass it — and would silently reintroduce the late read if a future caller omits the argument. Removing the default makes the invariant compiler-enforced.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| fingerprintMethod: string, | ||
| fields: Record<string, unknown> | ||
| ): Promise<StructuredAgentSessionWriteOutcome<T>> => { | ||
| const pressedFence = stateRef.current.fence |
There was a problem hiding this comment.
This reads the fence inside write, so it is captured before write's own capability probe — but cancel in use-structured-agent-session.ts:263 awaits supportsStructuredAgentSessionPromptCancel before it calls mutate, so for a Stop that names a prompt the fence is still read after that await and the Stop can still be retargeted at a runtime the pane replaced. The new comment ("read before any await, so a write never retargets") is broader than what this covers; either capture the fence at the caller, move the probe, or narrow the comment to the await it actually guards.
Technical details
# `pressedFence` only covers `write`'s capability await, not the caller's
## Affected sites
- `src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts:162` — `pressedFence` is read here, one layer below the caller that awaited.
- `src/renderer/src/components/native-chat/use-structured-agent-session.ts:262-267` — `cancel` awaits `supportsStructuredAgentSessionPromptCancel(target)` before `mutate('agentSession.cancel', ...)`; a Stop from the prompt/approval Stop button takes this path (`NativeChatStructuredSession.tsx:181`).
- `src/renderer/src/components/native-chat/use-structured-agent-session.ts:280` — `respond` awaits the question-answers probe before `mutate`; same shape for a free-form answer.
## Why it still retargets
1. `cancel(turnId, prompt)` reads `stateRef.current.fence` only after `await supports...` (which is a status round trip on a remote/paired host).
2. If the fence moved during that probe, `write` captures the *new* fence, so `send`'s `targetFence` equals the current fence and `settlesHere()` keeps the result.
3. The envelope's `expectedRuntimeFence` matches the new runtime, so the host accepts and runs the Stop against a runtime the pane replaced.
## Required outcome
- A capability probe awaited before a mutation must not be able to move the fence the mutation is sent with.
## Suggested approach (optional)
- Capture `const pressedFence = stateRef.current.fence` at the top of `cancel`/`respond` and, after the probe, drop when `stateRef.current.fence !== pressedFence`; or plumb an explicit pressed fence through `WriteArgs`. Removing the now-dead default on `send` would force callers to be explicit.# Conflicts: # src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts
6468680
into
brennanb2025/clear-starts-on-first-send
…23916 on main) (#24301) * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * fix(native-chat): the conversation outlives its agent Opening a chat no longer starts its agent. A conversation is reached through one host accessor that opens its journal at rest, and a send is what starts the agent, through the delivery loop. One idle sweep, every five minutes, stops an agent that has been quiet for thirty minutes and owes no work, then drops an open journal handle that is only a cache. Its record, tab, status row and readers stay. - hold and release are no-ops; hold still builds the host for shipped mobile builds. - The holders, the holds, the release clock and the exit respawn are deleted. - Options, the model list, the goal and the context meter answer at rest; a model pick at rest is recorded as intent for the next start. - Compact, rewind, clear and goal changes start the agent first. A send does too when a rewind is still in doubt after the conversation opens. - Orchestration routes mail and group addresses on ownership (the record plus the chat tab), not on whether the process runs. An open dispatch keeps its worker running. - The restart continuation is a send; Resume all holds each slot until the message is handed over or rejected. - A read error never replaces a loaded transcript, and shows the host's own words. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * fix(native-chat): a request that failed reads as failed A structured chat whose only message the agent's start refused read as a green finish, and a cancelled structured turn did too: the host published a verdict only for turn records, and structured rows carried no `interrupted`. The host projection now reads the session's latest request: its turn's outcome, or `failure` for a send the agent or its start refused. A send that was withdrawn, or left undelivered by a restart or a close, fails nobody and makes nothing listable. The ingest publishes `interrupted` as the hook lanes do, and every reader decodes the verdict through one accessor, so a failure reads Failed on the dot, the rollups, history and `worktree ps`, behaves like a cancellation in every clean-finish policy, and notifies as "failed". * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): a verdict change republishes the mobile status projection * refactor(native-chat): the store's retention trigger keeps its flag compare A verdict change always moves the completion clock the same check already reads, so a second verdict compare there caught nothing new. * test(native-chat): a user message the provider journaled keeps its session listed * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * fix(native-chat): a restart offer ends when the chat's agent starts again The offer used to end only when the chat's newest user message changed, because opening a chat started its agent and that start could not be told apart from real activity. Opening a chat starts nothing now, so the host reads the fact it already publishes: a chat's status row goes from not host-owned to host-owned exactly when its agent is started. At that edge the offer and any failure record for the chat are withdrawn, unless the start is a resume action's own (its continuation is the oldest undelivered message). A continuation and a message racing to be first are decided at acceptance: the continuation is refused, quietly and with nothing filed, when any other message was accepted since the restart. A failed continuation start leaves the offer retryable, and each resume action sends its own message id. Deleted: the newest-user-message comparison, its journal reader, the continuation filter, and the failure ledger's own "answered by the chat" check. The marker still carries its message id for one release, so the previous build can read it. * fix(runtime): end a transcript stream when its client unsubscribes Desktop: the IPC subscription controller was dropped as soon as the streaming handler returned, which for most streams is right after it binds. A later runtime:unsubscribe then found nothing to abort, so the host kept the subscriber and derived and sent every publish to a channel no one listened to. The controller now lives until the renderer unsubscribes, resubscribes the same id, or goes away. Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe with the stream's frame id, so the host ends that subscriber and leaves a sibling stream on the same socket running. The direct path now passes the frame id the relay path already passed. * fix(native-chat): a late provider-session update keeps a failed recovery record failed A provider-session heartbeat that rewrites a completed recovery record kept its interrupted flag but dropped the outcome it was copied with, so a live failed checkpoint read as a clean finish until the next status write. * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * test(native-chat): the terminal-bell check asserts the renamed verdict field The bell notification test still checked for agentInterrupted, which no longer exists, so it could not catch a verdict leaking into a bell dispatch. * fix(native-chat): a failed turn ranks like a completion for attention Attention readers (completion time, Smart Sort, sticky retention, Cmd+J Recent) now demote only a turn the user stopped. A failure is news the user has not seen, so it keeps its completion time, ranks in the Done class, stays retained after its pane goes away, and a retained failure reads failed in the worktree rollup instead of done. Clean-finish policy (hibernation, pane ownership, the value moment) still treats a failure like a stop. The retention trigger compares verdicts again: success -> failure no longer moves the completion clock. * fix(native-chat): one fact ends a restart offer: the chat moved on since the restart The offer is live while no other message has been accepted in the chat since the restart and its agent has not proved a start since. The offer list, the resume's reservation check and the continuation's acceptance check all read that one fact, so a message whose start then failed withdraws the offer too, and a stale click finds nothing to act on. The fact is read off the conversation's open handle, which the restart closed, so it is retired durably whenever it may have changed: a message accepted, a start proven. A close and reopen within the same run therefore cannot bring the offer back. A continuation rejected before it reached the agent does not count, so a retry after a failed start still runs. Deleted: the quit-time gate on withdrawal, which changed nothing because the withdrawal and the quit's own offer write share one queue; the per-action "withdrawn" flag and the separate acceptance check it paired with. * test(native-chat): an older build reads the restart offer this build records The offer lives in a file the previous release reads after a downgrade. Pin that against the pinned release's own capsule, and run the lane when the marker or the capsule changes. * fix(native-chat): read a restart offer against where the journal stood when it was taken "Since the restart" was read off the conversation's open handle, which the idle sweep closes: after a reopen, a message the user had already sent looked older than the handle and the withdrawn offer came back. The offer now records the journal position (epoch and sequence) at the moment it is taken, and a message accepted after that position, or a journal on another epoch, means the chat moved on. That is derived from the journal, so it holds across any number of closes and reopens. An older build's offer has no position; only a start withdraws it. Because the message half is now durable, the offer is no longer rewritten in the recovery file on every accepted message; a proven start still writes it, since only the host that saw the start knows of it. * test(native-chat): wait for the listing's retire write before reading the recovery file * refactor(native-chat): every journal row states which turn it belongs to Rows gain a turn scope stated by the write that creates them: the open root turn, or the conversation. A queued message takes its scope from its handover. Rows stored before scopes existed are placed on replay by the root turn open when they were created, so no persisted state is needed for them. Rewind keeps each retained row's scope and producer, so a subagent's row stays its own. * fix(native-chat): keep the terminal-backed chat's read error over its local echoes Messages winning over a read error is right for the structured chat, whose read retries and whose messages came from the transcript. The terminal-backed view assembles its list from local echoes too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no error. Only the structured pane now keeps messages over an error. * fix(native-chat): a start retries the exit settlement a failed journal write left owed An agent exit whose journal settlement write failed releases the lease latched until a retry lands. Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried it before the next app launch, and every send was refused. The start the send needs now runs the retry first, where the attach would. * fix(native-chat): a failed main agent reads failed while its subagents still work The verdict is now read from the main agent's own state, not the folded row: a main agent that is done and failed has a verdict even while its subagents keep the row working. Without mainAgent (history, worktree ps, older hosts) the old combined-done rule stands. Display marks the verdict through agentVerdictDisplayMark: a failure outranks every combined state on the agent's dot, label, tab badge, dashboard and activity rows; a stop marks only a done row, so a successful or stopped main agent with live subagents still reads working. Subagent rows keep their own state. The worktree card, terminal tab and Cmd+J rollups share one pane fold and rank a pending question, then failed, then working, monitoring, interrupted and done. worktree ps publishes the main agent's outcome on a working row, and the mobile mirror reads it. The store's change check, the paired-client mirror's equality and its epoch now see a verdict change on a working row, which otherwise moves no state or clock and left the worktree card reading working. Clean-finish policy is unchanged: a working row is never hibernated and has no completion time. * perf(native-chat): answer the owner check without opening the chat Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the answer comes from the session record alone. Reaching it through the accessor opened each resting chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it open for the idle window. It now checks the record and the adapter's support, as before this series, and opens nothing. * fix(native-chat): a read waiting on the session lock opens nothing once quit began The accessor checked for quit before queueing the open, so a read queued behind a session task ran its open after teardown had begun and indexed a journal no teardown step would close. The check now runs at the open itself. * test(native-chat): pin stated turn scopes, the upcast of unscoped rows, and rewind attribution * fix(native-chat): /compact is a message the chat sends, run as a turn of its own The conversation command RPC now accepts /compact into the queue like any send and answers once it is handed over. The delivery loop opens the command's own turn, starts the provider on it, and waits for the provider's end off the session's queue, so messages typed meanwhile are held and delivered after it, even when it fails. It settles by re-reading the journal: a child that died meanwhile already wrote the verdict. Stop ends the command at once. The 180 s completion window, the unconfirmed row and the recovery of an older build's compaction record are gone; that record no longer gates anything. On Codex the provider turn the command opens is claimed into the command's turn. * fix(native-chat): read a failed resume's chat before calling it retryable Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the restart, read from its journal. The failure list read it only for a chat already open, so once the idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did nothing. The list now opens the failed chats first, as the offer list does. * test(native-chat): type the provider event sink the settlement test reaches for * docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it The field is new: an old host sends no outcome at all, so a reader falls back to interrupted. The removed clause said old hosts send it on done rows, which never shipped. * fix(native-chat): say the structured read keeps trying only where it does The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an untranslated fallback whenever the read error had no text, and the empty state prefers any message. The view state now leaves the message out, so the structured pane shows that line and the terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an error frame, so it no longer makes the claim. * fix(native-chat): rows group under the turn their record names, not the one above them Each row's turn is the turn its stated scope names, anchored on the entry that opened it, or on the turn itself when the provider opened it unasked. So /compact groups its own rows and the previous turn is untouched, a message typed into a running turn joins it, and a provider-resumed turn folds under its own Worked-for. A row reporting how a turn ended, an error or the compaction separator, never folds. Desktop and mobile read the same keys; a host that states no scope keeps today's positional grouping. * test(native-chat): await the send's settlement instead of polling for the start The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a loaded machine outran. They now await the host's own settlement of the message. * docs(native-chat): the status-store listing rule names provider-journaled user messages * fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now dated by the resume action. Telling a rejected continuation from the user's own message read the operation ledger, whose rows expire after about a day; after that a failed resume stopped being retryable. The offer now records the continuation each action sends on its own capsule entry, bounded to the newest 16, so the ids end with the offer. The ledger read is deleted. * fix(native-chat): a /compact is not a request the sidebar, notifications or restart resume report The sidebar's prompt, preview, verdict and instant, the turn-completion feed, and the restart-resume marker read past a conversation command and its turn to the last real request, so a /compact neither notifies nor re-dates the row, and a command in flight is never offered as work to resume. An older client shown a command's turn in the legacy form names the session's own agent. * fix(orchestration): route no mail to a structured worker its orchestration released A structured worker is routed on ownership, and a resting worker's lease is released, so ownership held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one restarted its agent. Routing now also reads the orchestration's own resource row: once it is released, direct mail, group addressing and worker-show's addressable answer drop the worker, as they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored. * fix(native-chat): a failed retry names the user's prompt, not Orca's continuation A resume's continuation is written to the chat before its start, so after a failed attempt the chat's newest user message is that rejected continuation. A second failure then showed Orca's own restart text as the chat's prompt. A retry now keeps the prompt its first failure named. * test(native-chat): pin what a conversation command's admission refuses at rest and at handover * test(native-chat): tests merged from the base state which turn their rows belong to * fix(native-chat): a refused send notifies failed through the completion feed The host's completion feed followed only the newest turn, so a send the agent or its start refused, which creates no turn, read Failed on its row but sent no notification. The feed now follows the session's latest request, read from the projection the status feed already makes for the commit: a turn keeps its id, a refused send is named by its journal item key. It announces only while the session is idle, as the row reports a verdict, so queued sends refused one commit at a time notify once, and a withdrawn send falls back to a request already announced. * fix(orchestration): read the released row optionally, as the authority does worker-show's observation called the row lookup directly, which a runtime double without it threw on and failed the structured tab-retirement release. * chore(native-chat): one import per module and no unexplained casts in the turn-scope changes * test(claude): pin which turn a Claude row joins, including a subagent's after the turn ends * fix(native-chat): the status bar drops a restart offer the chat moved on from The renderer re-read the host's restart offer only when a failed chat showed activity, so after a message withdrew a pending offer the host answered no chats while the status bar kept counting one, and clicking it opened nothing. The same watch now covers pending offers: a status change in an offered chat asks the host again, once. * fix(native-chat): a refused steer is read from the turn its handover named The latest-request reader decided whether a refused send had joined a running turn by comparing host clocks: its handover time against the previous turn's end. The handover row now states the turn it delivered into, so the reader reads that instead and the clock comparison goes. A journal written before handover rows stated a turn is scoped on replay from the turn open when each row was written, which can differ from the clock reading only when a send and a turn's end share a millisecond. * fix(mobile): the native-chat controller contract carries the turn journal The controller and overlay already pass nativeChatTurnJournal, but the contract type never declared it, so mobile failed to typecheck. * fix(native-chat): the live turn is the running turn, not the newest user row A turn the provider opened on its own (a background wake, a resumed turn) anchors on its own record, but the list still treated the newest user row as the live turn. While such a turn ran, the settled user turn before it lost its duration and the running turn's own rows were drawn as settled, so its tool calls lost their live state. nativeChatTurnMembership now answers both questions from the turn record: each row's turn, and the live turn (the running root turn's anchor, else the newest user row, which is also all an unscoped host has). Desktop and mobile key liveness, the timing clock and the live status's row on it. * test(native-chat): a turn the provider opened keeps its own clock Pins that the local turn clock follows the live turn, so a wake after a settled turn does not restart that turn's clock when no host durations are recorded. * fix(native-chat): a running turn no message opened draws its status on no row Its live status belongs to the transcript-tail indicator alone. Once it settles, its duration draws at its first row as before; a running turn a message opened still draws on that message. * fix(native-chat): every copy of a row carries the main agent's own status History entries, sleep records and `worktree ps` rows carried a flattened top-level `outcome`, copied under different gates and without the main agent's clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type the live row already persists and sends, and every copy site takes it with `interrupted` through one function, `agentVerdictFields`. - The accessor reads `mainAgent` then the legacy flag; the mobile mirror matches it line for line. - Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a malformed value drops the field, never the record. - Mobile dates a main agent that failed under live subagents by its own clock, as desktop does, and its row equality compares `mainAgent`. - The activity feed reads a history entry's own `mainAgent` instead of rebuilding one; the sync key and history equality compare it. * test(native-chat): pin the worktree ps verdict across host and phone versions Pairs the real v1.4.212 host and phone row reader with this build: an old phone reads a new host's rows by `interrupted`, a new phone reads an old host's rows (no `mainAgent`) the same way, and a new phone reads a failure under live subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout now carries the phone's self-contained row reader, and the lane runs when the `worktree ps` row producers change. * test(mobile): name the parity table's row for its role * test(native-chat): a roster of idle or finished children does not keep an agent awake The sweep reads owed background work through the shared child-work liveness that upstream's release clock adopted; a child that went idle or finished is not work the agent still owes. * fix(native-chat): a request that settles while the user is asked something notifies once The completion edge waited for an idle session, and a pending prompt (including a subagent's approval) is not idle. Structured chat has no other attention producer, so a main turn that finished while a subagent waited on the user sent nothing until the prompt was answered. The edge now waits only on owed work (a running turn or an unanswered send), which the projection reports even beneath a pending prompt. A request that settles with a prompt pending announces once; the renderer words it "needs input" from the host status mirror's `attention`, and answering the prompt keeps the same request identity, so it does not announce again. The wire shape is unchanged. * fix(orchestration): a task dispatched into a resting structured worker keeps it running The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's process incarnation now counts, derived from the existing rows. * fix(native-chat): a command's wait ends when its child does The delivery loop waited for a /compact only on the adapter's compaction tracker, which learns of the child's end only on some exit paths: a Codex exit or close, and a Claude close, never reach it. The wait then never ended, so nothing queued behind the command was delivered again, Stop had no child to answer through, and the tracker's leftover entry refused the next /compact. Every way a child ends passes endProviderChild, so the host now offers a per-child end signal there. The loop races the tracker against it (the dead-generation settlement has already written the command's verdict), and on that end asks every adapter to release the command, so a later command runs and no later provider turn is claimed into the dead one. The adapters' own exit-time releases were unreachable (Codex) or covered one path of several (Claude), and are removed. The Codex RPC test harness moves to its own module so the exit can be driven through the real adapter's connection callback. * fix(native-chat): keep refusing sends during a command on an older host An older host's controller still refuses a send while a conversation command runs, so dropping the client's block turned every message typed during /compact into a 'not sent' row with Retry there. The block stays for hosts that do not run the command as a send-path turn, and goes only for those that do. The signal is one the client already holds: a host that runs /compact on the send path states a turn scope on every journal row it writes, the same fact turn membership uses to tell it from an older host. Both now read it from one predicate. On an empty conversation, or one whose rows all predate the upgrade, the signal is absent until the command's own entry streams in, so that brief window keeps the old local refusal; no capability or wire field is added. * docs(native-chat): comments stop describing the hold this PR removed Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it. Comment-only. * fix(native-chat): the completion says when the user is being asked A request that settles while a prompt waits on the user was worded "needs input" from the renderer's status-feed mirror. Remote clients receive the status and completion streams over separate sockets, so they can arrive in either order and the wording could be wrong both ways. The host already knows at emit time, so the completion now carries an optional `awaitingUser: true` in that case and omits it otherwise. The renderer words the notification from that field alone and no longer reads the status mirror. Old clients ignore the field and word by outcome; old hosts never send it. * fix(native-chat): a restart offer keeps the start its own continuation made Whose start ended an offer was decided at read time, from whether the offer's continuation was still the queued message. Once the provider refused that continuation, the child it had started read as someone else's start, so the offer ended and its failure showed no Retry. The delivery loop now records which queued message a start is for on the in-memory child, and the child's end carries it; the offer counts a start as its own when that message is one of its continuations. * fix(native-chat): a rewound turn still names the message that opened it A Codex rewind rebuilds the epoch without submissions, so each sent message survives only under its provider key. The kept turn records still named the submission key, so each turn anchored on itself and its rows grouped apart from the message that opened it. The rewind now renames the turn's opener along with the message. * fix(native-chat): Stop ends only the command it names Stop on a command turn abandoned whatever compaction the session had pending, so a late Stop for an earlier /compact cancelled the one running now. The tracker now ends a command only when the Stop names its turn, and the cancel reply reports whether it did. * fix(native-chat): an agent gets a full idle window after its owed work ends The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can read done before the lead's wake-up turn writes anything, and stopping in that gap loses the wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full window afterwards, as the release clock it replaced did. * test(claude): the options-read fixture runs a live child The fixture marked its conversation running with a hasProviderChild field the session type does not have, so the read took the at-rest path and refused a session with no record. It now carries a child, which is what the read checks. * test(native-chat): host tests reach its collaborators through a typed seam The rest-test rig and three test files read the host's private members with Reflect.get and cast the result. The host now exposes one test-only accessor, collaboratorsForTests(), and the subscribers class a subscriberCountForTests() beside its existing retainedActivityCountForTests(), so the tests are checked against the real types and the casts are gone. * fix(worktree-status): a departed agent's failure yields to live work on the worktree card A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome. * refactor(orchestration): one owner answers a structured worker's custody Routing, group addressing, worker-show and the idle sweep each composed their own reading of whether orchestration still holds a structured worker, so each new obligation or retirement state had to be added to every reader. structured-worker-custody now derives both answers from the worker-terminal list state coordinators see in worker-list: addressable is owned and not released, and owed work is an active custody or an unsettled task dispatched to the same incarnation. The owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests. * refactor(orchestration): owed work is an open dispatch on the worker's incarnation A supervised worker's own dispatch context stays open exactly while the worker is active, so the separate active-custody branch only repeated it. Owed work is now one fact, which also states the policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are written once at the top of the module. * docs(agent-status): a departed agent's failure ranks below live work on the worktree card * fix(native-chat): a restart offer knows its continuations by a tag in their id The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running action's id in memory. Both could disagree with the journal: past the cap an old rejected continuation read as the chat moving on, and a crash during a retry restored the failure's older entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer (its teardown and chat), then the action's own part, so any continuation of this offer, queued or rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own continuation the start was for, read against the stored marker. * test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait The tui-idle probe reads through readTerminal, which now awaits the structured worker check before the PTY read, so the probe's snapshot request starts a microtask later. vi.waitFor missed it on its first check and polled again at 50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot then resolved after the wait had already timed out, so the test passed without judging it, and the rejection landed before any handler was attached. Vitest reported that as an unhandled error and failed the shard. Polling every 1 ms sees the request within a few ms, so the snapshot is judged while the wait is still pending. * fix(native-chat): a message held behind /compact is drawn where it was handed over A message typed while /compact runs was drawn above the compaction's result, between itself and its own answer. The reducer kept every item at the sequence and timestamp of the row that created it, and a queued message is created at acceptance, long before the command it waits behind writes its result. The phone orders by that sequence and the desktop by that timestamp, so both put the message first. A queued message now takes its position from its handover row, the same row that already states its turn scope. Everything the agent did before the handover, a command it waited behind included, draws above it. This holds for every held message, not only /compact's, and needs no client change: every client, older builds included, reads the position the host publishes. A live batch already carries the item when its dispatch row lands, and history pages cut the reduced timeline by sequence, so paging stays contiguous. * fix(native-chat): a phone's send during /compact answers without waiting out the compaction A client that predates accepted-send replies, which is every phone build, has its send reply held until the host hands the message over. A message sent during /compact is not handed over until the compaction ends, so the phone's 15 s request timeout fired first and showed the message as unconfirmed. That wait now also ends once the message is queued behind a running command. This is read from the journal's running turn and needs no new state. Every other wait still ends at the handover: behind a starting child or an ordinary turn, and for restart resume, the command front door and orchestration, which keep the plain handover point. * perf(native-chat): a rewind places provider items with one pass over the merged rows A Codex rewind gives each provider item the old epoch never held the turn record for its provider turn. It found that record by scanning every merged row, restoring each row's body, once per provider item. That is quadratic, and it runs on the host's main thread up to the journal's 10,000-row cap, twice per rewind. A rewind record written before rows carried their scope holds no scope for any provider item, so it paid the full cost. The merge now indexes turn records by provider turn id once, keeping the first match as the scan did, and each provider item looks its record up. * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * fix(native-chat): a message waiting behind /compact is drawn after it until it is sent A message sent while /compact runs is placed where it was handed over. It was still drawn where it was accepted until then. /compact writes its result one step before the handover, so for that step the waiting message sat above the compaction's separator. A message the host accepted but has not handed over is not part of the conversation yet, so both clients now draw it after everything the agent has done. The shared projection moves it to the end, which is the order the phone draws. The desktop ranks it with the other not-yet-sent rows, after the streaming preview. At handover it takes its place from its handover row, which is also after the separator, so it never appears above the compaction it waited for. * fix(native-chat): the idle sweep reads owed work every tick Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it refreshed the clock at most once a window. Work that ended just before the next read left the agent to be stopped at that read, moments after the work ended, which is the gap the refresh was meant to cover. The sweep now reads owed work on every tick for a started agent, so the window always runs from the last tick that saw work owed. * fix(native-chat): a continuation handed to the agent stays sent The offer read its own continuation as not reaching the agent while its dispatch was pending, which also covered one already handed over and still unanswered. When the wait for that answer ended first, the failure it filed read as retryable, and a retry sent a second continuation to an agent that may have acted on the first. Only a continuation still queued, or rejected, is now read as unsent. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(cross-version): load the phone row readers without mobile's toolchain Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json extends expo/tsconfig.base.json, which the root-only cross-version lane never installs. The worktree ps verdict suite imported the current phone row reader from mobile/ directly, so CI failed with TSConfckParseError before any test ran. The harness now imports a copy of the working-tree reader placed under the checkout cache, where the root tsconfig applies, as it already does for the release checkout's copy. Both readers are still the real files. * test(cross-version): keep the checkout path-guard message and justify the copy import's cast * fix(native-chat): a command ends only by its own provider answer or its child's end Stop no longer settles a conversation command. It interrupts it like any turn, and when the provider cannot take that (Codex has not opened the command's turn yet, or Claude refuses the interrupt) it stops the child, whose dead-generation settlement writes the verdict. The pending command now lives on the provider child's own session instead of an adapter-wide map keyed by session, so it dies with the child and nothing has to release it. Claude's /compact is sent under a uuid the slot records, and only a root result naming that input (or naming none) ends it; its outcome is read with the ordinary result reading, so a stopped /compact is a cancellation. * fix(native-chat): a command's settle answers its message before ending its turn The two writes are not one batch. Writing the message's answer first means a crash between them leaves a running command turn, which the stale-turn sweep already settles, instead of an ended turn whose message reads as in flight forever. The settle now writes only while the command turn is still running. * fix(native-chat): "Worked for" counts from the handover, not the send A message held behind /compact, or behind a cold start, used to count the wait as the agent's work, although its row is drawn at the handover. Every handed-over submission's turn, the command's own included, now starts at the handover row's instant, falling back to the send time for a host that recorded none. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): the interrupted create's own retry continues again The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its own operation id, with a fresh start whose result nothing read. That fresh start passes with the released-reservation continuation deleted, so the case the fix exists for went untested. The retry and its assertion are main's again. * docs(native-chat): three comments that still had views starting agents A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction left alone would refuse every send, so no agent would ever start to finish it; and a current host raises the unattached read refusal only once quit began, with the attach window belonging to an older host. * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * fix(native-chat): a second Stop on a command ends its child; one compaction verdict for every provider A Stop's note now names itself in its key, so a later Stop on a command still running reads, from the journal, that the provider was already asked and never answered, and stops the child instead of interrupting again. Nothing is held in memory for it. Adds the rule both translators will read a compaction's end by: only a compaction the provider reported is a success; none after Orca's interrupt is a cancellation; anything else is a failure. A real Claude capture, pinned as a fixture, is why: a stopped /compact ends in the same success result as a finished one. * test(native-chat): a reader's open settles the turn a failed exit settlement left running An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle sweep closed, and a read that opens the chat before the restart restore reaches it. * test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner A subscription reads the conversation before it returns, so under load the two views took longer than the create child's 300 ms start, which then exited before the test checked that it had not. The child now takes a second to fail. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * refactor(native-chat): the provider's translator ends a command's turn; the loop holds no command state A conversation command is now a turn of the provider child's own journal pipeline. The adapter-wide tracker, its promise and the loop's settle step are gone. - Codex: the translator claims the provider turn that carries the command, scopes its rows to the command's turn, and writes the command's end in the same batch that settles that turn. Codex's own compaction marker is the success row. - Claude: the command's turn is the translator's open turn until the result that answers the /compact input ends it. The command's own frames, such as the continuation summary, its echo and "Compaction canceled.", draw nothing. - Both read the end with the one compaction rule: success needs the provider's report of the compaction; none after Orca's interrupt is a cancellation. - The message resolves at the provider's receipt, as any send does: the Codex ack, or the Claude slash-command waiter on its result. The host writes a command's end only when the provider never took it. - The delivery loop stops while a command's turn runs, and every journal commit re-wakes it through the session's serialize, so an end that lands while a step decides to stop is never lost. A child that ends first is settled with it. * test(native-chat): pin a command's end to real /compact frames and to each path it threads The captured /compact frames drive the Claude translator's command turn: a finished compaction ends as a success with only the separator drawn; a stopped one ends as a cancellation with no failure row, and the next send answers in its own turn; a result naming another input ends nothing. The command's end is checked at each point the ordinary result path threads through: the reopen latch after a failure, the settling of a child still working, the context facts the result reports, and the provider's own error row. On the host: a message held behind a command is handed over when the command ends just as the loop stops for it, a refused command settles as a failure and the loop moves on, and a Claude child that exits mid-command settles the command and hands what waited to a fresh child. * test(native-chat): tests merged from the base state which turn their rows belong to * refactor(native-chat): drop the child-end waiter nothing waits on A command no longer waits for its child here: its turn ends from the provider's frames or from that child's settlement, and the delivery loop is woken by the commit. The waiter and its test were left from the earlier shape. * fix(native-chat): a command holds the queue only while its child runs it The delivery loop stopped whenever the journal showed a command's turn running. When the command's child ended and its settlement could not be written, that turn stayed running with no child to end it, and the loop's gate kept it from ever starting the next child, which is what settles a gone generation's leftovers. Every later send was held for good, and Stop had no child to end. The gate now holds only while the conversation has a child: with none, the command belongs to a gone generation, and the loop's start settles it like any turn a dead child left running. * fix(native-chat): a Claude /compact succeeds only on its compaction boundary The command's evidence counted Claude's `compact_result: 'success'` status as the compaction done. That status comes before the boundary that replaces the history, so a Stop landing between the two read as a finished compaction even though no boundary was ever written. Only the boundary now counts, as the rule for both providers states; the capture's finished compaction carries one, so it still reads as a success. * fix(native-chat): a Claude child's exit says why the turn it ended stopped When a Claude child exited mid-/compact, the command showed "Worked for 0s" and no reason. The child's translator ends its open turn the moment the exit is reported, stamped with the exit's instant, so by the time the exit settlement ran nothing was running. The settlement recognises a turn the exit already ended by that same instant, but the Claude lifecycle event dropped it on the way to the host, which then used its own clock, matched nothing, and wrote no row. When the clocks did agree, the row was scoped to the running turn, of which there was none, so it landed outside the turn it explained. The exit's instant now reaches the host, and the exit row belongs to the turn the exit ended: still running, or ended by the translator at that instant. * fix(native-chat): a message waiting behind /compact draws below its live activity A message sent while /compact runs waits on the host until the command ends. Both clients moved it to the end of the transcript rows, but the running turn's live activity line ("Compacting the conversation") draws after every row, so the waiting message sat between the command and its own live status. A row that is queued, and not what the live turn is for, now draws after that live activity: on desktop outside the transcript window, below the activity line; on the phone in the list footer, below the live status. A message whose own start is pending still draws above the activity that start reports. * fix(native-chat): only a running command holds a message below its live activity A message is accepted, then handed over a moment later, and in between it reads as waiting. Every message waiting behind a live turn drew below that turn's activity line, so an ordinary message sent while the agent was working crossed below "Thinking" and jumped back up once it was handed over, on desktop and phone. Only a conversation command's turn holds the queue on the host. A message now waits below the live activity only while the running turn is one a command opened, read from the entry that opened it. The phone test also typechecks, which the mobile test ratchet requires. * test(codex): the claim test names its notification params as a record * test(native-chat): a read that reaches a crashed chat before the startup reconcile settles its turn On desktop the chat on screen at relaunch reads before startup reconciles the leases, while the dead process's lease still reads live. The open settles the turn it left running anyway, and the restore that follows finds it settled. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * docs(native-chat): drop the removed dispatch hold from six comments A worker's session no longer takes a dispatch hold, and no release clock rests a chat by visibility; the agent-launch comments, the abandon test, the teardown test and the refusal census still said so. * test(native-chat): rest the owner-status chat through the idle sweep, not a hold The activation-gate test from #22808 put its chat at rest by holding and releasing it, and passed the release-clock grace. This branch deleted both, so the case threw before it reached its assertions. It now moves the host's clock past the idle window and lets the sweep stop the agent and close the conversation, then asserts the same owner answer and activation gate. * fix(native-chat): show the structured pane's retrying line when a read fails The read transport always hands the pane the host's words, so the error state's "Orca keeps trying to load it" line, which showed only when there were none, was never seen: the pane showed the host's text twice, as its subtitle and on the status line under it. The structured pane now always says its read keeps retrying, and the host's text stays on the status line. The terminal-backed chat is unchanged. * fix(native-chat): a send the provider never received after a restart has no verdict Restart reconciliation rejects a crash-stranded send that is absent from a trustworthy provider history with reason 'not_delivered'. Nobody failed that send, but the verdict allowlist did not name it, so after a crash the chat read Failed, was listed, and could notify "failed". Give the reason a shared constant (persisted value unchanged), add it to the no-verdict set, and treat it as an internal marker so the Retry row no longer shows the raw string. * fix(native-chat): a failed Codex compaction's late completion writes no turn of its own Codex ends a failed turn with an error and then still completes it as failed. The error settled the compaction and released its claim on the provider turn, so the completion read that turn as an ordinary one and wrote a stray record. The claim now lasts until the completion, which adds nothing to a command the error already ended. * test(native-chat): the mid-command exit case resumes its next child as a real one does The case's fake started every child as a newly created thread with the same generation. The store refuses a created link once the conversation has a thread, so the next child's start failed and wrote its own error row, which landed before or after the case read the journal. The next child now resumes the thread under its own generation, and the case reads the journal once the waiting message is delivered, which also proves the loop moved on. * fix(native-chat): a /clear that never committed no longer locks the chat A /clear wrote a durable "prepared, outcome unknown" record before starting the replacement conversation. When that start was refused without a definite answer (or Orca died), the record stayed forever, and while it did the chat refused every send, /compact, a new /clear and rewind. Its only exit was a rerun under the same operation id, which only the renderer held. The record guarded nothing the process does not already know: a clear in flight holds the session's serialize for its whole run and the command controller refuses sends meanwhile, and the replacement's id and start operation are pure functions of the clear's operation id. So the clear now writes nothing durable before its commit, the gates refuse only a committed clear (an older build's prepared record is inert), and a clear with no committed answer reruns: a same-op retry re-attaches the same replacement, a new op id runs a fresh clear. A crash between the replacement's start and the commit leaves a replacement record nothing points at. Verified: it has no tab, is not in the replacement list, and a restart opens and starts nothing for it (restore reads only the visible tab index); restart reconciliation releases its lease like any dead owner's. In a live process its agent is stopped by the idle sweep like any quiet agent. Session History lists provider transcripts and only annotates them with an owner, so it can list this only if the provider wrote a transcript for a thread that never got a message. Its record stays on disk, as every closed chat's does; the store deletes none. * fix(native-chat): a Codex rewind the provider did not keep no longer fails every attach When Codex acknowledged a revert and Orca stopped before proving it, the rewind stayed prepared with providerApplied set. On the next attach, recovery read the provider's history, found the target turn still there (provider-refused), and threw, because that settlement was limited to reverts never sent. The throw ran inside the attach, so every attach, and every send that needs one, failed for good. The journal is replaced only once the provider proves the revert, so both the provider and the journal still hold the target turn: settling the rewind refused is consistent whether or not the provider acknowledged it. * test(native-chat): a clear retried after a crash starts no second replacement The replacement's id is the only thing that keeps a retried clear from leaving a second one, and no test held it across a restart. * chore(native-chat): the clear rerun comment claims only the stable replacement id * test(native-chat): wait for a send's background start before the refusal oracle removes its store An accepted send wakes the delivery loop, which starts the agent in the background. The oracle's teardown disposed the loop but did not wait for that start, so its lease write could create a temp file in the store directory while the directory was being removed, failing the test with ENOTEMPTY about one run in four. The teardown now drains tracked starts before it closes the journals. * fix(native-chat): a start a message waited on gets one failure row, the delivery loop's When a queued message's start failed, two writers could report it under the same row: the delivery loop, when the adapter settled the start without proving it, and the exit settlement, when the child's exit landed. The last one won, so the chat's row could name a different cause than the one the message was rejected with, or be written twice. The exit settlement now writes the start's row only when no message is queued and the loop has not already recorded that start. A start for a command, goal change or rewind, with nothing queued, still gets its row from the exit. * fix(native-chat): a /compact whose start failed says to run /compact again The failure-words context named only /clear as a command to retry, so a /compact whose agent failed to start read "Send your message to try again." on its row, its rejected message and the command reply. The context now carries any conversation command; the host derives it from the oldest message still waiting on the provider, which is the one a failed start fails first, and the /compact reply names it directly. * fix(native-chat): a Codex /compact ends only on its turn's completion, below Codex's own error row Since only turn/completed ends a Codex turn, Codex's turn-ending `error` is a row inside the still-open command turn, and the failed completion that follows it is the command's end: completed, outcome failure, at the completion's receipt time. The command's own "Compaction failed" row was written on that completion too, so a failed /compact read its reason twice. The command turn now notes when Codex's turn-ending error for the turn it carries was written as a row, and its end then adds no second row. A retried stream error ends nothing and is not counted. The flag that let the error end the command and kept the claim until the completion is gone with the error-driven end. A test replays the captured failed compaction from the real app-server through a claimed command turn. * test(native-chat): main's crash-turn test states its row's turn, and a dead /compact settles on its recorded exit Two tests the main merge brought together: - The crash-turn test from #23456 writes a turn record through the event sink without options; every row here states its turn scope, and a turn record's is the thread. - The /compact whose exit settlement could not be written no longer stays running until the next start: main now settles an open chat from the exit it recorded, so the command reads interrupted before the next message, which is then delivered. * test(native-chat): main's new journal tests state each row's turn The crash-turn, stale-turn and sink-queue tests main added wrote rows without a turn scope, which every item write now states. Rows written inside a running turn name that turn; the sink-queue batch and a send handed over with no live turn name the thread. * fix(native-chat): draw a queued turn's message after the earlier turn's rows A message sent while A runs is written to the journal when it is sent. When the provider queues it (Claude answers it after A), A's remaining rows - its last tool run and its answer - are written after that message, and the message's own turn opens only after them. Grouping put those rows in A's turn, but the transcript still drew them in journal order, below B's bubble and bar, where A's answer read as B's reply. This is the residual #23671 left open. A message that opened a turn now draws after the earlier turns' rows the journal wrote after it, just before its own turn's rows (nativeChatTurnDrawOrder, returned by nativeChatTurnMembership as drawOrder). Desktop and mobile both draw in that order. A steer, and a message that has opened no turn yet, stay where they were written. It applies on hosts that state turn scopes and, through journal order, on older ones. * test(native-chat): run #23026's Stop tests against #23059's command turns Two of #23026's tests call APIs #23059 changed, and failed af…

ELI5
When a chat action failed to get an answer (for example the connection dropped), Orca kept that action's id and quietly reused it the next time you pressed the same thing. The host then treated the new press as a repeat of the old one and did nothing. So picking an option again could leave the chat on the other one, a second Stop could do nothing, and setting a goal again could leave it cleared. Now every press is its own action. A harmless repeat, such as a second Stop of a turn you already stopped, or a second tap on a queued message's Send, is answered from what the chat already records, so it changes nothing and says nothing false.
The plan this belongs to
Goal: one operation id per user action for every structured chat write. Each press is new, only an explicit retry of that one action may reuse an id, and the host answers a harmless repeat from what the chat already records. It ships as:
What Changed
The problem. The desktop app kept operation ids in memory keyed by chat, action and exact payload, and kept one after a dropped connection, an "unknown" answer or a refusal. The next identical press, however much later, reused it, and the host replayed the old outcome instead of running. The phone's Stop and its /compact and /clear did the same. Seen as:
Scope. Structured Claude and Codex chats, on desktop, paired desktop and phone, local and SSH hosts. That includes the Send, Delete and Resume buttons on messages queued while the agent works (#23731, #23736). Sends on the desktop already used a fresh id per message and are unchanged. Phone sends are step 2.
Before / After
Mechanism
use-structured-agent-session-mutate.ts: every call mints its own id. The per-payload id map, the kept /compact and /clear id, and the shared "does this write name its target" check (structured-agent-session-operation-identity.ts) are deleted.use-mobile-structured-agent-mutation.ts,use-mobile-structured-send-with-outcome.ts); the id-retention helpers are deleted.structured-agent-session-turns-prompt.ts);structured-agent-session-turns-cancel.ts);structured-conversation-command.ts). Only a successful clear counts; another client, or a cleared conversation reopened from history, still reads "cleared".structured-agent-session-turns-cancel.ts). "The provider had already finished this turn." is gone. The one "Cancellation requested." note is keyed by the turn it stopped, found by the turn's id whether it still runs or has ended (structured-agent-session-turn-stop-notes.ts), so another Stop of that turn rewrites that row instead of adding one. A Stop that didn't do what was asked still says so: the refusal row while the turn keeps running, and "Cancellation was not confirmed." when the adapter failed.structured-agent-session-queued-mutations.ts, unchanged; now pinned by tests).agent-session.repeated-stop.v1, says the host writes no row for a Stop that stopped nothing, so a repeated Stop is quiet. The desktop and phone check it, and against a host without it, each still joins a second Stop of a turn to one already on its way, as before this PR, so an older host never writes the false row.conversation-stop,prompt-canceland the newrepeated-stop) moved together intosrc/shared/agent-session-stop-capabilities.ts.protocol-version.tsre-exports them, so no importer changes. Main had filled that file to its line limit, and this keeps one Stop's wire rules in one place instead of trimming unrelated lines.Why
A press is a user action, and an action's identity cannot be inferred from its payload: the same pick an hour later is a new choice. Retries of one action stay possible where a stored record of that action exists (a desktop message's outbox entry; #23524's host-side /clear retry), so the client never has to keep an id it can't re-derive. Repeats that are harmless are answered from state the host already holds, not from a remembered id.
Alternatives considered:
Differences from the common pattern
agent-session.repeated-stop.v1. It exists only so a new app talking to an older host doesn't write the false row. The follow-up deletes both client joins once no supported host lacks that capability.Linked Issue
No issue. Found while fixing Stop and send delivery in #23026 and #23553.
Visual Proof
A Stop of a turn, sent twice under separate ids, and a Stop of a turn that already ended. A double Stop under separate ids is what a phone double-tap sends, or Stop pressed on two devices. Live on a second Mac with the real Claude CLI 2.1.285, in a production-renderer build driven over CDP. Two arms, each proven from its built bundle:
d4e7d53c7bd, where "The provider had already finished this turn." appears twice in the main bundle;8867990f21d, where it appears 0 times and the capability string 4 times.Each Stop goes through the host's own
agentSession.cancelfrom the page, naming the running turn. The rows below were also read back from each arm's journal. After this PR, the note's key is the turn id, and it is written once per stopped turn.Before, two Stops at once: the false line under the stopped reply:
Before, the second Stop 1.5 s after the first had answered:
After, both at once and 1.5 s apart: no false line. The two runs stopped at the same point and rendered identically, so one image shows both:
Before, a Stop naming a turn that had already finished:
After, the same Stop writes nothing:
Phone QA: BLOCKED. The emulator QA skill this needs can only be started by a person, not by this session, and no other way of driving the phone is used in its place. So these phone checks were not run live: overlapping taps on a queued message's Send, Stop tapped twice against this PR's host, and the phone's Stop against a host without
agent-session.repeated-stop.v1. The 68 mobile tests above cover them, including the phone's Stop join against a host without the capability, and both presses going out against one with it.Earlier heads: nothing regressed. Live QA with the real Claude CLI on a second Mac, in a production-renderer build, driven over CDP. Three arms: this PR before its last rebase (
09fa9e908eb), its base (f26e8039693), and the current head (89564af9862) for the /clear cases, each proven from its built bundle. Everything the app can produce behaves the same on every arm, so these runs show nothing regressed. The cases this PR fixes all need a lost reply, which the app cannot produce on demand; the tests below cover them.A Stop pressed twice: the reply stops and no error shows:
An option picked A, then B, then A again ends on A:
/clear pressed twice on a conversation with a turn lands on one new, empty conversation (current head):
/clear on a cleared conversation reopened from history still says it was cleared (current head):
The same text sent twice is sent twice, and a question card resolves once:
Testing
I manually tested these changes locally
Automated tests added/updated, or explained why not below
New
use-structured-agent-session-mutate.test.tsx(a fake host ledger) andstructured-agent-session-repeat-press.test.ts(the real host): an option picked again, a goal set again, a named and a background Stop pressed again, the joined Stop reporting once, and the same answer again.structured-conversation-command.test.tsandstructured-chat-tab-table.test.ts: a /clear double press and a retype after a lost answer land on the current conversation with nothing started; another client and a reopened conversation still read "cleared"; a failed clear runs fresh.structured-agent-session-host.test.tsand-prompt-cancel.test.ts: a second prompt Cancel, sent while the first is in flight or after it, answers quietly and asks the agent once.Mobile
mobile-structured-composer-command.test.tsandmobile-structured-operation-id-retirement.test.ts: fresh ids per press. Against a host without the capability, Stop joins the one on its way. Against a host with it, both presses go out.New
structured-agent-session-repeated-stop.test.ts(the real host and journal): a second Stop after the first settled writes nothing; two Stops at once leave one row (keyed by turn; keying by operation id again fails it); a Stop of a turn that ended by itself, or two Stops that both found nothing, write nothing (bringing back "already finished" fails 7 tests); a relaunched host writes nothing more.New
structured-agent-session-queued-repeat-press.test.ts(the real host): Send, Delete and Resume of a queued message pressed twice under separate ids: sent once, deleted once, resumed once, and the second press answered, not refused. Removing the host's "already sent" answer fails the Send test. Mobileuse-mobile-structured-agent-session-queued.test.tsx: overlapping card presses carry their own ids; it fails with main's id sharing.structured-conversation-compaction.test.tsandstructured-conversation-command.test.ts: a /compact pressed again under a new id while one runs is refused and runs once, and one pressed after it ended runs again; a second /clear pressed while the first runs is refused and makes one new conversation. Joining the same client's /clear instead fails the /clear test.Desktop
use-structured-agent-session-mutate.test.tsx: the Stop join is used only against a host without the capability; with it, every press goes out under its own id.Each new test fails on the base; removing each rule fails its test.
tc:node,tc:web, the mobile typecheck and its test ratchet, oxlint,check:code-quality:changed,check:react-doctor:changed, the reliability gates and the anti-slop audit pass on the current head. 21 related host and desktop test files (227 tests) and 6 mobile files (68 tests) pass.AI Disclosure
Review
Four review loops and an architecture challenge.
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Merge order: #23524, then #23935, then #23916, then #23917.
Mixed versions (clients and hosts update separately):
agent-session.repeated-stop.v1): the app joins a second Stop of a turn while the first is on its way, as before. A second Stop pressed after the first one's answer arrived can still write "already finished" there, as can a Stop of a turn that ended by itself. Clicking the same answer again after a lost reply shows "The question changed".Follow-ups:
agent-session.repeated-stop.v1(see Differences, temporary).Known, not changed here:
Same on macOS, Linux, Windows and SSH hosts.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)Author: @BrennanKB5