Repository navigation
fix(native-chat): a message is accepted, then delivered - #22821
Conversation
No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths.
Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com>
`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.
Co-Authored-By: Claude <noreply@anthropic.com>
Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com>
The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com>
The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com>
…alled appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com>
A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow.
The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged.
…t decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind.
…ation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it.
…at changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write.
…ration A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release.
A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted.
…dle enters only through the map
…ackfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed.
The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown.
Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read.
# Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
b3ef438 to
6bfe2e7
Compare
A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".
A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.
Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.
A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.
Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.
There was a problem hiding this comment.
✅ No new issues found in this delta.
Reviewed changes
The delta since the prior pullfrog review (e6dadd1838) is two merges of main (6374f35a27, 724b961264) with no authored commits. The first resolved 23 files both sides had changed; the second was clean. The reconciliation adopts main's newer native-chat settlement model and drops this branch's own.
- Settlement is now derived from the lease, not a durable latch.
structured-agent-session-settlement-retry.ts(and its test) is deleted, andsettlementRetryRequired/settlementRetryId/settlementRetryare gone from the lease, the adapter's ended event, and Claude session state. NewsettleStaleStructuredAgentSessionState({journal, sessionId, fence, acquisitionGeneration, deathEvidence})settles stale items and running turns fromturnVerdictFromDeathEvidence; onlyexit-observedearnsinterruptedplus an end time and the exit copy. - Attach no longer refuses on a pending settlement.
runAttachdrops the pre-reserve retry and itsagent_session_ownership_unknownrefusal; it readspriorDeathEvidencefrom the record before the reserve clears it and settles insideonAttached, beforebindAndDrain, only when an owner was acquired. - Read/restore settles before publishing.
restoreOneStructuredAgentSessionReadUnderSerializenow awaitssettleStaleState(sessionId, opened)beforeonReadable; theretrySettlement→settleStaleStaterename runs throughrestart-restore,reveal, andreadable-restorer, andrestoreOneUnderSerialize/restoreReadableUnderSerializeare removed with no dangling callers. - An unexpected exit files its reason on the death evidence rather than setting a retry latch; whatever the exit settlement could not write is re-derived at the next acquire or read.
- Smaller main adoptions reconciled here:
resume-eligibilityusesagentSessionLeaseIsReleased;agent-session-mutation-envelopeadds theterminalOwnerRefusalMessagebranch for aconflictedlease;structured-agent-session-acquisition.ts:43wires main's newonSpawnedcrash-mid-start hook;host.tsdrops the old restore arity.
Verification: pnpm tc:node is clean, and the 10 affected suites pass (122 tests): provider-child-record 15, accept-then-deliver 19, wedged-profile-migration 18, lease-adjudication 25, unexpected-exit 11, stale-turn-verdict 9, reveal 13, legacy-handoff-record 4, restart-restore 3, crash-mid-start 5.
The Stop/handover serialize-key race flagged in the initial review is untouched by this delta and remains open.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
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.
…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.
…tion 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.
…arlier 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.
Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch.
…d runner's budget
…, 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.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The delta since the prior pullfrog review (724b961264) is six commits, two of which change production behavior: a chat whose last start failed is no longer restarted by a view bind, and a gone generation's running turn is now settled wherever a conversation opens.
- A view never restarts a chat whose last start failed. New
failedProviderChildStart(!session.child, last ended during startup, cause not a user Stop) is threaded through the holds aslastStartFailed; a hold on such a session returns without callingensureProviderChild, so only a send or an explicit attach retries. The same predicate replaces the inline check in the delivery loop'sstartThatFailedWhileQueuedand theexitedDuringStartupcheck in unexpected-exit recovery — both behavior-preserving, and every otherlastEndedChildproducer (user-stop,evict, failed re-attach, the producer-lesshost-stop) leaves it false or forgets the session first. - A gone generation's turn is settled wherever a conversation opens.
settleStaleStructuredAgentSessionStatemoves into the one journal open, guarded to skip only areserved/livelease (an acquisition reserves first and settles from the evidence it read before). The separate read-restore/reveal settlement step and its deps are removed, so a send that opens a chat a dead process left running now settles it before any reader sees it. - Tests. The R1 attach test seeds a real running turn and asserts it ends
interruptedat the exit's receipt with the exit row; the R2 test starts its child with an attach instead of a second view;append-deliverycounts status rows by row id rather than by text; the two new waits get a 10 s budget.
Verification: pnpm tc:node clean; 94 tests green across the twelve affected suites (view-start-after-failed-start 1, send-open-stale-turn 2, provider-child-record 15, restart-restore 3, reveal 13, readable-restorer 1, unexpected-exit 11, holds 17, hold-resume-race 12, append-delivery 5, dead-generation-settlement 10, legacy-handoff-record 4); oxlint/oxfmt clean on the changed production files.
The Stop/handover serialize-key race flagged in the initial review is untouched by this delta and remains open, so approval is withheld.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…n 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.
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.
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 69ef1da1-9e89-4e25-8de4-16551f81a0ba
📒 Files selected for processing (7)
src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.
| // No child in this process writes to a journal nobody had open, so whatever it shows running | ||
| // belongs to a generation that is gone, whatever the lease still claims. Settled before any | ||
| // reader or child sees it. | ||
| if (!options.acquisition) { |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
set -euo pipefail
ast-grep outline src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts --match 'performAttach' --view expanded
rg -n -C 6 'onAcquisitionReleased|onAttached|openConversation|settleStaleStructuredAgentSessionState' \
src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts \
src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts \
src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.tsRepository: stablyai/orca
Length of output: 14487
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- attach flow ---'
sed -n '1,280p' src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts
printf '%s\n' '--- attach failure ---'
sed -n '1,180p' src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts
printf '%s\n' '--- stale settlement definitions and callers ---'
rg -n -C 12 'function settleStaleStructuredAgentSessionState|settleStaleStructuredAgentSessionState|settleFailedPostAcquisitionAttachment' src/main/native-chat/agent-session-wire
printf '%s\n' '--- conversation open ---'
sed -n '1,180p' src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts
printf '%s\n' '--- orchestration attach callback ---'
sed -n '140,215p' src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.tsRepository: stablyai/orca
Length of output: 42159
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- released-child cleanup ---'
rg -n -C 30 'function endReleasedChild|const endReleasedChild|endReleasedChild' src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts
printf '%s\n' '--- post-acquisition settlement implementation ---'
rg -n -C 20 'settleFailedPostAcquisitionAttachment' src/main/runtime src/main/native-chat srcRepository: stablyai/orca
Length of output: 22542
Settle stale state on an acquired attach failure.
If performAttach fails after openConversation succeeds but before onAttached, the acquisition path skips settleStaleStructuredAgentSessionState. The failure cleanup does not call that function, and endReleasedChild returns when no child is indexed. Add equivalent stale-state settlement for this failure path before leaving the opened conversation retained.
There was a problem hiding this comment.
✅ No new issues found in this delta.
Reviewed changes
The delta since the prior pullfrog review (8f66a252) is two commits: a fix to when a conversation open settles a gone generation's turn, and a test-determinism change.
- The conversation open's stale-turn settlement keys on the opener, not the lease's claim status.
openStructuredAgentSessionConversationJournalused to skip its crash-boundary settle whenclaimStatusreadreservedorlive. A lease a crashed process leaves in recovery also readsliveuntil the next acquire resolves it, so a send that opened such a chat (phone/CLI after a crash on a host that could not prove the old owner gone) skipped the settle, and a start that then failed left the dead turnrunningfor every reader. A newStructuredAgentSessionConversationOpenOptions = { acquisition?: boolean }is threaded throughopenStructuredAgentSessionConversation,openStructuredAgentSessionConversationJournaland the delivery'sopen; onlyrunAttach(and the test helperopenTestAttachConversation) pass{ acquisition: true }, because the acquisition's reserve already cleared the death evidence and it settles frompriorDeathEvidenceinsideonAttached. Every other open settles unconditionally. Safe because the open only runs when the session is not indexed in this process (the send path early-returns viasessions.get;restoreOneStructuredAgentSessionReadUnderSerializereturns onhasSession), so a genuinely live in-process child is never opened over. - The "view binds while the create is still starting" case is now deterministic. The
initDelayMs20/300 race is replaced by aninitGatethat everyinitializationResultawaits; the test releases the create only after both holds bind, so a loaded runner can no longer let the create's exit win the race. - An
indeterminateowner probe is pinned.send-open-stale-turnnow parameterizes overpid-absentandindeterminateand asserts the turn endsunverifiablein both; theindeterminatecase fails under the old guard (verified: the turn staysrunning).
Verification: pnpm tc:node clean; 132 tests green across the affected suites (send-open-stale-turn 4, view-start-after-failed-start 2, read-restore 4, restart-restore 3, reveal 13, acquisition-options 22, crash-mid-start 5, wedged-profile-migration 18, dead-generation-settlement 10, accept-then-deliver 19, provider-child-record 15, settled-attach-retry 5, recovery-resolution 7, append-delivery 5).
One earlier finding is untouched by these commits and remains open: the Stop/handover serialize-key race on structured-agent-session-host-mutations.ts (Stop remaps to compact-cancel:${sessionId}, so a just-withdrawn message can still be handed over), which is why this review is not an approval.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found in this delta.
Reviewed changes
The delta since the prior pullfrog review (05b8b9307b) is one commit: d5716f3028 Merge main into PR 1. No authored commits, no production change on this branch.
- Forward-merged
main(484598b18e → f72079bd21). The merge brings in 13 commits ofmain— mobile typed question answers (#23458), terminal input-mode recovery and subscription registration (#23474, #23472, #22948), usage/analytics session identities (#23478, #18759), settings search, tab-ranking and agent-launch caller selection, the Linear team-key fix, and the reopened-tab close fix (#22922). - The merge is fully mechanical.
git diff --name-only 484598b18e..f72079bd21andgit diff --name-only 05b8b9307b..d5716f3028are the same 87 files, and none of them is a file this branch changed. So no file was edited by both sides and there is nothing to reconcile — every adopted file equals itsmainversion and every branch file equals its05b8b9307bversion. The PR'snative-chat/agent-session-wirecode is untouched. - The changes nearest this PR's surface are
main's own and compose correctly.structured-agent-session-tab-retirement.ts/tabs-close-actions.tsdrop the caller-suppliedtabIdin favour of a derivedagent-session:${sessionId}host id (#22922);structured-agent-session-params.tsaliasesMAX_RESPONSE_OPTION_ID_LENGTHto the newAGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH(#23458); mobile prompt-answer routing is adopted wholesale.
Verification: pnpm tc (node + web + cli projects) is clean on the merged tree.
One earlier finding is untouched by this merge and stays open: the Stop/handover serialize-key race on structured-agent-session-host-mutations.ts (Stop remaps to compact-cancel:${sessionId}, so a just-withdrawn message can still be handed over). Approval is withheld for it.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Review status at
|
…ves-agent branch Brings in #22821's crash-mid-turn settlement (the attach marks its open as an acquisition and every other open settles, whatever the lease still claims), main's plain-words refusal copy and the rest of main. Resolutions: the stale-turn test keeps PR 1's probe cases and reads the journal through the async snapshot; the view-start test keeps PR 1's start gate, with each view a subscription instead of a hold. Locale catalogs auto-merged with every key from both sides.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
🟠 Major · Depend on the logical target, not the target object. · use-structured-agent-session-outbox.ts:94
src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts:94
🩺 Stability & Availability | 🟠 Major | ⚡ Quick winDepend on the logical target, not the
targetobject.If a caller creates a new object for the same target on each render, this effect can requeue a
dispatchingentry after apendingresponse clears the in-flight guard. The next render can then send the same operation again before journal admission.Proposed change
- }, [owner.fenceRef, owner.ownerChange, sessionId, target]) + }, [owner.fenceRef, owner.ownerChange, owner.targetKey, sessionId])
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: e96a8f1c-0c89-40e3-832d-2ab184f7cdc5
📒 Files selected for processing (8)
src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsxsrc/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.tssrc/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsxsrc/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsxsrc/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsxsrc/renderer/src/components/native-chat/use-structured-agent-session-outbox.tssrc/shared/structured-agent-session-outbox.tssrc/shared/structured-agent-session-send-disposition.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.
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.
…t 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.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The delta since the prior pullfrog review (d5716f3028) is one merge of main and two authored commits.
- Merged
mainat89cf55dfc8(740ed1fc0e). Brings eightmaincommits, nearest of which is #22999: a renderer-wide rework of chat-write refusal (agent-session-refusal-notice.ts+agent-session-write-notice-text.ts, i18n keys, a 323-line test) that carries the failure on the outbox entry aslastFailure. Its files overlap this branch's outbox, send-disposition andNativeChatDeliveryRetry, and the merge layered the branch'srejectedstate on top of main's shapes.git diff 89cf55dfc8 HEADover the overlapping files shows only the branch's additions, so none of main's #22999 was lost; the branch'sreconciledRejectionNoticeis superseded by main's entry-carried reason. The other merged main commits (#22913, #22929, #23475, #23306, #23380, #23480) are outside this subsystem. - Dropped the branch's second error formatter (
7002b1bfb7).agent-session-error-text.tsand its test are deleted, and the composer's catch returns to main'serror.message ?? String(error). Every composer-path write (threadGoal.change,runConversationCommand,setOption) goes throughmutate/write, which catch and return a typed outcome, so the catch now only sees a local throw. - Pinned the rejected-while-closed reason (
cb5ce82ab7). The reopen test now also asserts the host's reason rides on the Retry row, not just that the message reads as not sent.
Verification: pnpm tc:web clean; 64 tests green (agent-session-refusal-notice 26, outbox-rejection-cause 7, outbox-fence 3, outbox 21, composer-send 7).
The Stop/handover serialize-key race (structured-agent-session-host-mutations.ts, PRRT_kwDORpCz1s6l-MFW) is untouched by this delta and remains open, so approval is withheld.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Keeps main's tree plus only this PR's own changes.

Scope: which chat this affects
experimentalStructuredNativeChatsetting, which is off by default (src/shared/default-global-settings.ts:137).Stacking
This PR was stacked on #22812 (writes are named by their target), #22811 (every journal append reaches the chats that are open) and #22808 (a chat's owner is reported from its record). All three are now merged into main, and the base is
main. Main was merged into this branch three times, as merge commitse6dadd1838,6374f35a27and724b961264, so the branch carries main up to484598b18eand the diff against main is this PR's own change only. The commits:c753e8ab9a(a message is accepted, then delivered).82f9bfd7b3,f3e52d206b,29a340b0b4,c7a652fe29,34d0bb0d34,46b6472ab8,283819d17aand40dabe893f; then, on top of the refactor,4993696a53(a stopped child ends on one reading of its stop),a2ad8f2b63(the host advertises accepted sends),48def968ef(an attach never opens a journal of its own),da65d574f8(the outbox does not resend on a fence move),0184a3d814(a user's Stop and a host stop end a start differently),12577d8486anda59eed9da6(the restart offer skips queued messages),ccaf412f96(a start that dies while a message waits on it is that message's failed start), and test pins, comment fixes and one typing change (0efca9330d,74d6506167,9c671f1983,80ccb332a3).d2f646b870makes the provider child its own record, andaa56b5033cmakes the delivery loop the only thing that settles a message its start or child failed.e6dadd1838brings in main with fix(native-chat): name a chat write by its target, not the owner generation #22812, fix(native-chat): every journal append reaches the chats that are open #22811, fix(native-chat): report a chat's owner from its record, not its running agent #22808, fix(native-chat): keep an idle chat alive while its subagents or background commands run #22794, fix(native-chat): send typed question answers as structured answers, not an option id #22793 and fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID #22516;6374f35a27brings in fix(native-chat): every lease latch has a way to die #22820 and fix(native-chat): keep terminal pane chat ownership stable #22984;724b961264brings main to484598b18eand had no conflicts. The older merges in the history (8fd4558d48,12dde3710b,d23cca4adb) and the stacked PRs' own commits brought those PRs in before they landed; their content is now main's and adds nothing to the diff.c39151d838(a view never restarts a chat whose last start failed),0224459529andad04c3be46(whatever a gone agent left running is settled at every open but an acquisition's), with tests9613178eda,2276cef4b0,9275869673,158ca0a5ee,8f66a25289and05b8b9307b.ELI5
Before, sending a message to a structured chat whose agent was not running made the app start the agent first, and the send only answered once the start was done. If the start failed, the message was refused and went back into the composer with an error, and a Claude prompt sent during startup could come back "unconfirmed". Now the app takes the message straight away and shows it in the chat. A small per-chat delivery loop then starts the agent and hands the message over. If the agent can't start, the chat shows one red row saying why, and the message is marked "not sent" with a Retry button. Opening or coming back to that chat does not try the start again; only sending does. Stop takes back messages that are still waiting and stops only the agent; the chat stays open.
What Changed
Before
restartOwnerForSend), before the message was recorded. The client waited for the whole start.agent_session_owner_restart_failed. Nothing was recorded, and a second send restarted again.hasProviderChild,providerChildPhase,acquisitionGeneration,fence). Only closing the chat ended an agent as a whole, and an attach that failed after acquiring an agent forgot the conversation: it closed the journal and dropped the entry. An eviction marked every pending message "unknown", including ones no agent had ever received.After
Accept. A send is accepted inside the conversation's serialized queue. The host records it with
handoverRecorded: true, publishes it, and answerspending. It no longer needs the writer lease: a send is a write to the conversation, not to the agent.The provider child is its own record on the conversation,
child: {generation, fence, phase} | null(structured-agent-session-provider-child.ts). It is entered only byindexProviderChild, after an attach has fully succeeded, and ended only byendProviderChild, which an exit, a failed re-attach, Stop and eviction all share. The end is matched on generation and fence, so a late ending for an older agent cannot end a newer one. A failed attach writes no child, so nothing is unwound.endProviderChildalso records how the agent ended (lastEndedChild: a user's Stop, a host stop, an exit, a failed attach or an eviction, and where the journal stood), in memory only.Delivery.
structured-agent-session-delivery-loop.tsruns while any message is queued, meaning accepted but not yet handed over. Each step is its own serialized task and re-reads the journal and the child record:adapter.awaitStarted, which resolves with the reason when the start did not land).dispatch{pending}row is written before the adapter call.One settler. The delivery loop is the only thing that settles a queued message because of a start, an agent or a leftover. An agent's exit only ends the child record; the loop reads
lastEndedChildto decide. The only other writers of a queued message are Stop (withdraw) and closing the conversation (abandon).One red row per failed start. A start the loop needed and didn't get writes one status row with
tone: 'error', keyed by the start: the agent's generation, or the oldest queued message when no agent was published. Every queued message is rejected with the same text. That includes a Claude CLI that spawns and then dies while starting. A second report of the same failure revises the row instead of adding one. A start that Stop cancelled writes nothing.A start someone else began. A start that dies while a message waits on it, such as the one a chat's view began when its tab opened, is that message's failed start. The delivery loop settles the message with that start's error row and starts nothing more. A message sent after the loop has settled the failure gets a fresh start. One accepted in the same instant, before the loop's next step, is settled with that failure, as it already is when the loop's own start fails.
Only a send retries a failed start. A view does not start a chat whose last start died during startup; its hold still registers and resolves, and only a send starts the agent again. This is one rule,
failedProviderChildStartinstructured-agent-session-provider-child.ts: there is no agent now, and the last one ended during startup and not by the user's Stop. It is derived on every read from how the last agent ended (lastEndedChild, in memory, never persisted), and the next agent to start replaces it. Three places read it: the view's hold (lastStartFailedinstructured-agent-session-host-lifetime.ts, used bystructured-agent-session-holds.ts), exit recovery (structured-agent-session-unexpected-exit.ts), and the delivery loop (startThatFailedWhileQueuedinstructured-agent-session-delivery-loop.ts). After an Orca restart that memory is empty, so the first view starts the agent once again. A user's Stop is not a failed start, so the next view starts the agent as before.An orchestration worker whose agent cannot start fails its
worker-startwithdispatch_preamble_undeliveredand the agent's startup error, the same way a chat's message reads "not sent". It is never reported ready.Stop. With no writer lease and no fence check, Stop withdraws every queued message first (
provider_cancelled_before_start). It then interrupts a running turn, or stops a Claude agent that is still starting. Stopping ends only the agent (stopStructuredAgentSessionAgentUnderSerialize): its lease is handed back and the chat is told it is idle, while the journal, the view's holds and its subscribers stay. With nothing to stop, it succeeds and does nothing. A withdrawn message is removed from the chat with no error, and its text is not put back in the composer.Quit and tab close. Quit stops each delivery loop before its next step, waits for a start already in flight, and stops the agent that start produced. Quit and closing the chat's tab both reject whatever is still queued as
provider_closed_before_delivery, with or without an agent.Restart offer. "Resume interrupted chats?" counts only work an agent had. A chat whose only work is a message still queued at quit is not offered.
Settlement is derived from what the journal recorded:
rejectedwith the causerejected host_restarted_before_deliveryby the delivery loop that the next open wakesrejectedby the delivery loop with the exit reason (the exit only ends the agent)unknown, as todayunknown; provider history decides it under a won leaseunknown, orrejectedif the agent never proved its startunknownA queued message keeps the chat "working", and counts as owed work so an idle release waits, whatever its fence.
An
unknownmessage blocks/clearand/compactonly if it is live on the current fence. Doubt left by an earlier agent no longer blocks them.A compaction or rewind found prepared when a conversation opens is settled at that open, with no view needed. The open runs only when no agent is indexed, so such a command was started under an agent this process no longer has. A compaction settles as
unknownwith a status row, a Claude rewind settles as unsupported, and a Codex rewind the provider already applied and verified is completed. A Codex rewind only its provider can prove still waits for an attach (see Known limits).The open cursor is scoped to its epoch (
journal.wroteBeforeOpen(sequence)), because sequences restart when an epoch is replaced.Fences on writes. Conversation writes carry the record's fence, and an agent's own writes carry that agent's fence. The journal refuses any row below the highest fence already written (
assertJournalFence, called atsrc/main/native-chat/agent-session-journal/journal-row-writer.ts:24), and the record's fence only goes up, so a conversation write at the record's fence is always accepted.Host capability. The host advertises
agent-session.accepted-send.v1inRUNTIME_CAPABILITIES(src/shared/protocol-version.ts).Renderer. On a host that advertises accepted sends, the outbox no longer treats a fence move as a new owner: it does not resend, unblock or reset its probe (
useStructuredAgentSessionOutboxOwnerChangeinsrc/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts). Against an older host, or if the capability probe fails, it behaves as before. A message the journal rejects after the host accepted it becomes arejectedoutbox entry, and its Retry row states the reason once through the per-message failure record (lastFailure) that plain-words refusal copy on main introduced: the host's own reason for a start failure, "Your message was not sent." for an internal reason, and "Message was not sent." only when no reason is stored. Retry resends the same text under a new id. A rejected entry no longer blocks later messages;unconfirmedstill does. A message rejected while its chat was closed reads the same way on reopen, reason included, and a rejection recorded before the send's ownpendinganswer is kept. Write refusals use main's typed write outcome; this PR adds no second formatter.What was deleted, added and kept
restartOwnerForSend,structuredAgentSessionSendNeedsOwnerandrecordFailedRestart.claude-structured-session-startup-gate.ts: held prompts,holdClaudeStartupWrite,rejectClaudeStartupWrites,failClaudeStartupGate).onAttachFailedforget, and the attach's ownmarkPendingSubmissionsUnknown.restoreReadableUnderSerialize,AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS, and the pre-dispatch refusal write insideperformSend.hasProviderChild,providerChildPhase,acquisitionGenerationand the storedfence), replaced by the child record.structured-agent-session-conversation-open.ts, the one open: the recovering open plus the crash boundary, used by accept, read and attach. An attach now always adopts the conversation's open journal.structured-agent-session-delivery-loop.ts,structured-agent-session-host-delivery.ts(the open and the loop together), andstructured-agent-session-start-failure-row.ts, the one start-failure writer.structured-agent-session-provider-child.ts, the child record's writers andfailedProviderChildStart, andstopStructuredAgentSessionAgentUnderSerialize, which stops the agent and keeps the conversation.AgentJournalSubmission.handoverRecorded(on the submission row) andhandedOverAt(derived from thedispatch{pending}row).journal.wroteBeforeOpen(sequence), backed by the cursor (epoch and sequence) a handle found when it opened.adapter.awaitStarted, which is Claude's startup-settled promise, resolves with the reason a start did not land, and also resolves when the agent is closed.agent-session.accepted-send.v1capability.admitteddispatch outcome. Claude and Codex both return it for every successful write whose identity arrives later with the echo, not only for held prompts. A handed-over message stayspendinguntil that echo.claude-structured-session-startup-state.ts, whichsetOptionstill needs.Main's changes carried onto the agent record
The
structured-agent-session-*files below are insrc/main/native-chat/agent-session-wire/. Each rule has one copy, in this PR's shapes.hasOwedWorkcheck instructured-agent-session-host-lifetime.ts, which readsagentChildWorkLivenessover the adapter's background tasks; there is no second owed-work check.prepareClaudePromptReplyinsrc/main/claude/claude-structured-prompt-ownership.ts, andrespondToStructuredAgentSessionPromptinstructured-agent-session-host-mutations.ts, which takesAgentSessionPromptRequestthrough the same mutate path; main's code is unchanged.settlementRetryRequiredorsettlementRetryId; they are only stripped at load insrc/main/runtime/agent-session-store-transaction-queue.ts, and stale state is derived from the record's death evidence by the onesettleStaleStructuredAgentSessionStateinstructured-agent-session-dead-generation-settlement.ts.openStructuredAgentSessionConversationinstructured-agent-session-conversation-open.ts, for every opener except an acquisition (a send, Stop, a reader, a reveal or a restart restore). An acquisition says so with{ acquisition: true }and settles inonAttached(structured-agent-session-attach-orchestration.ts) from the death evidence it read before its reserve cleared it (priorDeathEvidence). A turn with no proof its owner exited becomesunverifiable, never "stopped"; only an observed exit marks itinterrupted. This includes a send that is the first thing to open a chat after a crash and whose start then fails: main settled that case in the send's restore, which this PR deletes, and the open now does it.Why
One place owns delivery. The send, the view's hold and exit recovery each used to be able to start an agent, each with its own failure handling and its own way of writing into the chat. Now a send only records the message, and the loop is the one thing that starts an agent for a send, hands a message over, and settles a message whose start failed. The loop runs exactly while something is queued, so there is no loop state to go stale.
The agent is its own record. This is the first change in which a chat must outlive its agent, so ending an agent can no longer mean ending the chat. With the agent as one record, entered in one place and ended through one function, every way an agent ends is the same operation, and nothing hand-unwinds a subset of fields.
Settlement is derived, not stored. Whether a message reached an agent follows from two rows,
handoverRecordedanddispatch{pending}. That is why the delivery loop, Stop or a close can reject a queued message outright, as provably unwritten, while a handed-over message stays in doubt.A failed start is derived too. Whether the last start failed is read from how the last agent ended, which the host already holds, so there is no flag to clear: the next agent to start supersedes it, and a relaunch starts with none. Keeping it across a restart would stop a fixed CLI from coming up after a relaunch, and the next PR removes view starts altogether.
Crash leftovers need no latch. A handle closes only with nothing queued, so a queued row at or below the handle's open sequence must come from an earlier process. The open wakes the delivery loop, whose first step rejects it.
Handover is its own serialized step because the queue is first in, first out. A Stop sent while a start holds the queue runs before the handover that would have written the message, so the withdrawal always wins.
Alternatives considered
Differences from the common pattern
handoverRecorded, stillpending, nohandedOverAt, rather than kept as a separate queue entity.pending.Differences from the plan
admittedis kept. The plan said to delete theadmitteddispatch outcome with the held prompts. But Claude returns it for every successful write, not only held ones:src/main/claude/claude-structured-dispatch.ts, the end ofdispatchClaudeTurn("The write is the admission signal …settleWaiterfinishes the job"). Codex also answersturn/startbefore the echo. Only the held-prompt path is deleted.src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts(the doc comment oncommitStructuredAgentSessionLaunchPrompt) deliberately returns the committed row without waiting for dispatch, so there is nothing to put a budget on.timeoutMs.startedevent by one serialized step, so failing on the phase alone rejected healthy sends.endProviderChilddoes not close the event stream, which the plan had it do (see the last deviation above).Known limits, handed to later PRs
prepared, orprovider-succeededwithouthydrationVerified, is not settled when the conversation opens, because proving it needs a live Codex process (recoverCodexRewindinsrc/main/codex/codex-structured-rewind.ts), and a send is not accepted past it, because that recovery rebuilds the journal throughreplaceJournalEpoch, which deletes every row, including a queued message. On this PR a view still attaches and recovers it; PR 2, which stops views starting agents, settles it another way.Mixed versions
agent-session.accepted-send.v1(every released client, and mobile until it can show a rejected message in place): their send reply is held until the message is handed over or rejected, with a 120 s budget (STRUCTURED_AGENT_SESSION_START_WAIT_MS). A longer start replays through the same wait. Clients withoutagent-session.pending-send-result.v1wait, as before, for the provider's answer.clientKind: 'runtime', which is whyDESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIEScarries it.agent-session.accepted-send.v1in its own runtime capabilities. A desktop or paired-web client that sees it stops treating a fence change as a reason to resend or unblock.rejectedentry instead of blocking the queue.handoverRecordedandhandedOverAtare optional fields. Old readers drop them: the zod schema strips unknown keys, and the journal row parser ignores them.rejectedstate. An older build drops entries in a state it does not know, so after a downgrade a message that was not sent disappears from the chat instead of offering Retry. For a message the host never recorded, its text lived only in that entry and is lost. Keeping an older state instead would make the older build send a rejected message again, which is worse.MIN_COMPATIBLE_RUNTIME_CLIENT_VERSIONpasses the first release where every client advertises accepted sends.Linked Issue
STA-7716, STA-8245
Visual Proof
Round 3, after the merges with main (a second Mac,
158ca0a5ee)Live Electron QA on a second Mac with a real Claude CLI and an isolated app profile. For the failed-start lane, a small wrapper in front of the Claude CLI made it exit with code 1 during startup ("not signed in"), and was then removed before Retry. The commits after
158ca0a5eeare one settle-on-open fix (ad04c3be46) and tests.724b961264: the agent is kept while the work runs and released only after it ended.724b961264: the answer reaches Claude and the turn continues.158ca0a5ee: opening it gives 1 start and 1 row, and the view does not restart it; a send gives 1 start, 1 row, and "Message was not sent." plus Retry; returning to the chat later adds nothing; Retry delivers. At724b961264the same chat had 3 starts and 2 rows.724b961264and 2 of 2 at158ca0a5ee.A fresh chat whose start fails: opening it shows the create's one red row, and nothing more.
After one send: one red row below the message, and "Message was not sent." with Retry.
Returning to the chat later adds no row.
After the wrapper is removed, Retry delivers.
Send while "Claude is still starting" is showing: the message waits, then the reply arrives.
An idle chat with a background subagent (#22794): the chat was released only after the subagent ended.
A structured question answered (#22793):
Cold send:
Rounds 1 and 2
Live Electron QA on macOS with a real Claude CLI, an isolated app profile and a separate home directory. A small wrapper in front of the Claude CLI could make the chat's agent start slowly, or exit with code 1 right after it spawned. Round 1 ran at
c753e8ab9a, round 2 at0184a3d814, and round 2b atccaf412f96. Codex was not run live. The desktop has no Stop button while an agent starts (see Known limits), so Stop during a start was sent through the chat's own Stop request.ccaf412f96fixed it.12577d8486fixed it.A start that fails, before this PR (
12dde3710b): the failure row is a normal grey row.After (
ccaf412f96): open the tab and send while the CLI fails. The message is shown while the agent starts:One red row, and "Message was not sent." with Retry:
After Retry, the message is delivered and the red row stays visible:
Agent killed with the chat open, then a send (
ccaf412f96): one red row under the message, and Retry.Quit with a message queued, then relaunch (
ccaf412f96): the message reads "not sent" with Retry, and there is no restart offer.Stop during a start (
0184a3d814): no error strip, no "still starting" banner, the chat stays open; then a later send is delivered.Restart continuation with a 40 s cold start (
0184a3d814): the offer, then the continued turn.Testing
Test names carry short tags:
Wplus a number is the item in the plan's test list (W29 to W35 cover the agent record and the single settler),R1marks tests for the agent record, andR2marks tests for the delivery loop being the only settler. Every host test runs against a real host, record store and journal, with a live subscriber opened before the action, and asserts the journal and the frames that subscriber saw.Accept, then deliver (
structured-agent-session-accept-then-deliver.test.ts)unknown, and provider history is not read at open (W4′b, W4′c).unknown, and an idle agent is not evicted while a message is queued (W24).The agent record and the single settler (
structured-agent-session-provider-child-record.test.ts)A view after a failed start (
structured-agent-session-view-start-after-failed-start.test.ts): with the real Claude adapter and a fake CLI that exits during startup, a fresh chat starts once for the open and once for a send, with one row each, whether the view binds after the create died or while it is still starting; holding the view again starts nothing and adds nothing.Settle on open (
structured-agent-session-send-open-stale-turn.test.ts): after a crash and relaunch, a turn the lost agent left running is settled asunverifiablewhen a send opens the chat and its start fails, and when a reader opens it, both when the old owner is proven gone and when nothing proves it.Background work (
structured-agent-session-owed-work-release.test.ts): a subagent, a background command and a monitor each keep an idle session until they settle, on this PR's accept-then-deliver timing.Restart offer (
structured-agent-session-restart-resume.test.ts): nothing is offered for a chat whose only work is a queued message, and a handed-over message is offered under its own identity even with a newer queued one.Renderer: on a host that accepts first, fence moves during a send cause no resend and a blocked message stays blocked until Retry, while an older host still resends (
use-structured-agent-session-outbox-fence.test.tsx). The capability hook reads a local yes and no, a remote yes, and a failed probe as an older host. A message rejected from the journal keeps its reason, and Retry uses a fresh id.Also: the host advertises the capability; a queued row is left alone by provider-history reconciliation; the restart continuation reaches
acceptedafter a 40 s start; the preamble and mailbox pointer wait for delivery; reply timing for the desktop and paired lists; the cross-version held reply; a failed start's rows are counted by row, so two rows with the same text fail (structured-agent-session-append-delivery.test.ts).Ablation: each new test was checked by removing or reverting the lines it pins on a copy of the file, and went red on its own assertion (not a timeout) before the file was restored. The one exception is the same-operation-id resend test: its property comes from accepting before any start, so it has no single line to remove. Existing tests were moved to the new timing, and tests that only pinned deleted internals (held prompts, journal replacement on re-attach, pre-dispatch refusal persistence, the old entry fields) were rewritten or removed.
Validation at
05b8b9307b:pnpm tc:nodeandpnpm tc:webpass.pnpm exec oxlintis clean, andpnpm run check:code-quality:changedreports 0 findings.tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts) passes 16 of 16.ORCA_*environment unset (native-chat, claude, codex, runtime, shared and cross-version-wire) passes apart fromclaude-structured-real-cli, which needs a real Claude CLI, and load timeouts in files this PR does not touch, which pass when run alone.Not run: SSH, Windows, Linux, and live Codex. Full
pnpm lintandpnpm buildare left to CI.I manually tested these changes locally
Automated tests added/updated, or explained why not below
Review
Author: @BrennanKB5
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
beforeDispatchhook no longer has a host caller. The restart continuation's "still resumable" check now runs at acceptance.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)