Repository navigation
fix(native-chat): a chat drops an agent that can't take messages, and settles an exit Orca can't prove right away - #23022
brennanb2025 wants to merge 107 commits into
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
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.
A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none.
Brings in main's squash of L (#22820), which replaces the L commits this branch carried.
…leased fence When delivery sees a child die starting, it now ends the child itself, and that stop can release the lease before the provider's own exit event is handled. The exit event then finds no child and does nothing, so the open reader never heard the fence the release moved to; before, only the exit path released on a start death, and it always published the fence. The stop's release now reports whether it released, and a stop whose cause is an observed exit publishes the new fence the way the exit path does. Whichever of the two gets there first releases, so readers hear it once. Other stops keep leaving readers their own fence, as before.
…moves its store The oracle's teardown copied quit's but skipped its drain of tracked attaches. The last accepted send wakes delivery, whose start is still writing the record store when the test removes the directory, so rmdir failed with ENOTEMPTY under load. Quit drains that start before closing anything; the teardown now does too.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental re-review of the two commits since the prior pullfrog review (e0c3f03d25), both on top of the base-sync merge 5ab8deaae0.
- A delivery-observed start death now tells readers the fence its release moved the lease to. The stop's wind-down publishes the new fence when its cause is an observed exit, closing the case where the stop released the lease before the provider's own exit event was handled — the exit event then found no child and published nothing, so an open reader kept the pre-release fence. Whichever path releases first publishes; the other sees
session.child === nulland returns early, so the fence is announced exactly once. - The lease release now reports whether it happened.
releaseStoredStructuredAgentSessionOwnerreturnstrueonly on the proven-release branch; the childless, stale/unreleasable, andrecoveringbranches returnfalse, so no fence is published for a transition that kept it. - Refactor, no behavior change. The inline
publishFencelambda passed to event recovery became a private class field reused by the lifetime context; class-field initialization runs before the constructor body, so it is defined when the recovery constructor reads it. - Test teardown drains delivery starts.
abandonHostinstructured-agent-session-refusal-retry.test.tsawaits tracked attaches before closing, matching quit, so the store directory is not removed under an in-flight delivery start.
Verified locally: both touched test files green (15 tests, including the new fence-publication case) and pnpm tc:node clean. The new test fails without the fix — the reader would observe only the pre-release fence. The prior informational note about the limited "at app start" sweep remains open and non-blocking; no prior inline threads are open.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Keeps main's tree plus only this PR's own changes.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (15)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review. 📝 WalkthroughWalkthroughStartup waits now return structured failure details, and the delivery loop can stop a child that remains in the starting state. Attachment and shutdown paths settle pending work and distinguish confirmed child exits from unproven stops. Unproven stops hand ownership to recovery, which handles indeterminate probes and reports probe errors. Eviction continues through selected best-effort failures, while failures in other teardown steps still stop the sequence. Priority: ➖ Normal Merge Risk: 🟡 Moderate · up to A journal write failure can prevent Retry from starting a fresh agent, and a failed attachment can leave its operation unsettled. Address these failure paths before merging. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to Recovery is better defined for an agent that cannot accept messages, but two error paths can still interrupt cleanup and leave a chat unable to replace its agent promptly. The affected chat mode is experimental; no cross-user exposure was established. Retained concerns
Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
⚔️ Resolve merge conflicts 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: bc793980-f71c-46f7-bea4-52f120a65d00
📒 Files selected for processing (28)
src/main/claude/claude-structured-session-adapter.tssrc/main/claude/claude-structured-session-startup-state.tssrc/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.tssrc/main/native-chat/agent-session-wire/structured-agent-session-adapter.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach.tssrc/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.tssrc/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.tssrc/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-eviction.tssrc/main/native-chat/agent-session-wire/structured-agent-session-holds.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.tssrc/main/native-chat/agent-session-wire/structured-agent-session-lease-release.tssrc/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-unproven-stop.test.tssrc/main/runtime/agent-session-surface-release-transition.tssrc/main/runtime/claude-structured-fake-connection-test-fixture.tssrc/main/runtime/claude-structured-session-integration.test.tssrc/main/runtime/structured-agent-session-runtime-exit.test.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.
| : 'exit-proven' | ||
| } | ||
| input.onAcquisitionReleased?.(cause, { rootGone: exitProof !== 'unproven' }) | ||
| await input.onAcquisitionReleased?.(cause, { rootGone: exitProof !== 'unproven' }) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Protect the durable settlement from a rejected onAcquisitionReleased callback.
The callback on Line 27 is now awaited, and it can return a Promise. If that Promise rejects, the function exits before settleFailedPostAcquisitionAttachment runs. The failed attachment's operation row and reservation then stay unsettled, and the caller receives the callback error in place of cleanupError. The async endReleasedChild path runs journal settlement and publishes status, so a rejection is possible. Catch the callback error, keep the settlement step, and include the callback error in the thrown error.
🛡️ Proposed fix
- await input.onAcquisitionReleased?.(cause, { rootGone: exitProof !== 'unproven' })
+ try {
+ await input.onAcquisitionReleased?.(cause, { rootGone: exitProof !== 'unproven' })
+ } catch (releaseError) {
+ cleanupError = new AggregateError([cleanupError, releaseError], 'acquisition release callback failed')
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| await input.onAcquisitionReleased?.(cause, { rootGone: exitProof !== 'unproven' }) | |
| try { | |
| await input.onAcquisitionReleased?.(cause, { rootGone: exitProof !== 'unproven' }) | |
| } catch (releaseError) { | |
| cleanupError = new AggregateError([cleanupError, releaseError], 'acquisition release callback failed') | |
| } |
| return 'continue' | ||
| } | ||
| return this.fail(sessionId, { | ||
| const step = await this.fail(sessionId, { |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Stop the failed child even if failure recording rejects.
If this.fail() rejects while writing the journal, execution skips stopAgent. The outer catch attempts another journal write but does not end the child. A startup failure combined with a journal write failure can therefore leave the failed child indexed and prevent Retry from acquiring a new agent. Attempt the stop in a finally path for the still-matching child, and report the journal error separately.
… same step An unproven stop, a sink-failure end, and a failed attach that could not prove its child gone each left the lease `recovering` until the chat's next start or app start. The stop's wind-down now ends with a best-effort recovery step, and a failed attach runs recovery before it returns, both inside the session's serialize. A recovery that fails is reported and leaves the latch for the next start; the stop or attach outcome stands.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental re-review of 2dda86515e, the one commit since the prior pullfrog review (787c8ceb24); f525b750f5 in between was a base-sync merge. The change makes a lease the host hands to recovery conclude in the same serialized step rather than at the chat's next start.
- The stop's wind-down now runs recovery.
STRUCTURED_AGENT_SESSION_EVICTION_STEPSgains a best-effortresolve-recoverystep at the very end, afteracknowledge-releaseso the adapter's route is already dropped. It is gated on a wind-down actually being owed.stopStructuredAgentSessionAgentUnderSerializewires it toruntimeState.resolveRecoveryand re-publishes the fence when the cause is an observedexit, so an unproven stop, a sink-failure end and a quit stop release the lease in-step instead of leaving it latched. - A failed attach concludes its own unproven acquisition.
runAttachreadsrecoveringBeforeright after the initialresolveRecovery, then on a failed attempt runs recovery in itsfinallyonly when the lease was not alreadyrecovering. That distinguishes the attempt's own unproven acquisition from a lease a crash left behind, which the next start still owns; a recovery failure is reported and the attach keeps its own outcome. - Tests cover the new triggers and the failure paths. The W40, sink-failure and W43 cases now assert the lease is
releasedwith no send and no new child; new cases cover the failed-attach trigger, an owner probe that cannot answer (released, nothing signalled), a probe that throws (reported, stop still finishes), and the fence an open reader sees when a concluding recovery moves the lease.
Verified locally: structured-agent-session-eviction.test.ts, structured-agent-session-holds.test.ts, structured-agent-session-unproven-stop.test.ts and structured-agent-session-recovery-exits.test.ts all pass (58 tests). The only prior inline thread (the sink-failure settlement) is resolved, and no pullfrog threads remain open. This also supersedes the earlier informational note about the recovering lease concluding at app start: it now concludes in-step for all three triggers, and only survives a recovery failure or a host crash.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…verifiable Recovery resolution let a throwing owner probe escape, so the stop's and the failed attach's recovery left the lease `recovering` until the next start, and a start whose probe kept throwing failed every send. A probe that throws has not verified the owner's identity, so resolution now reads it as `indeterminate`: the lease is released with no signal and no death evidence, and the error is reported once through the host's error hook. A probe that throws after the stop ladder signalled the verified owner finishes the ladder the same way. Only a failed store write still leaves the latch, for the next start to re-derive.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental re-review of 5f972a4d43, the one commit since the prior pullfrog review (2dda86515e). It changes how recovery resolution reads an owner probe that throws.
- A throwing owner probe no longer strands the lease. New
probeRecordOrIndeterminatewrapsdeps.probeRecord; the first throw within a resolution is reported through a new optionalonProbeError(wired toonEventSinkError) and the probe reads asindeterminate. Recovery then takes the existing "identity cannot be verified" branch: it releases the lease without a signal rather than rejecting and leaving the lease latchedrecoveringfor a later retry. - The stop ladder finishes through the same wrapper. A probe that starts throwing after a signal no longer aborts
stopOwnerAndReprobe; it is read as indeterminate and the ladder proceeds through SIGTERM and SIGKILL. - Tests pin the new reading.
recovery-resolutionadds a throwing-probe case (released, unsignalled, error reported once) and a mid-ladder throwing case;recovery-exitsnow forces the attach's latch by failingtransitionHandoffinstead of throwing the probe, andunproven-stop's two probe-failure cases assert release-without-signal and a plain next send.
This is consistent with releaseUnprovenAgentSessionOwner's documented contract ("a recorded owner whose identity cannot be verified"), and the indeterminate outcome already released this way — the commit makes a throw equivalent rather than a separate path. A conflicted terminal owner is still waited out, since an indeterminate probe is not proven dead. Verified locally: the three touched test files pass (34 tests).
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Review status: ready for reviewHead Review loops. Seven review loops ran, plus an independent architecture review after the same kind of issue came up twice. Each loop's fixes were pushed with a test that fails without them.
Readiness checklist. Run at the start (two medium findings, both fixed above) and twice at the end. The final pass found no defects. Live QA (desktop app on a separate Mac, real Claude Code, before and after builds):
Screenshots are in the PR body. Not covered by live QA. Codex scenarios were not run live. Unit and integration tests cover the Codex paths. Live Codex QA was deferred until the rig can run without touching the machine's real Codex and Claude settings. Known weaknesses and follow-ups are listed in the PR body. The next small PR (F8) removes the second shutdown pass that can make a Retry after a failed start wait about 7 seconds instead of about 2. |
PR 2 removed the holds, view starts and exit respawn, and made the delivery loop the only starter. This PR's behaviour is carried onto that code: - The delivery loop keeps its stop for a start it sees die, next to PR 2's startedFor start; the stop is wired from the host alongside the loop's start. - The sink-failure end keeps its lifetime context; the restart it used to share with the holds is gone with them. - The stop's wind-down composes PR 2's "the row belongs to the conversation" with this PR's fence publish for a start seen to die and its recovery step. - The best-effort-steps assertion moves from the deleted holds test into the eviction test. - PR 2's sweep retry test now fails its stop by throwing: an unproven stop is no longer a failure here, and a new case pins that it concludes on the same tick. - PR 2's start-failure-writer test hands the adapter's raw reason, which the loop words, as awaitStarted now returns.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/main/runtime/claude-structured-session-integration.test.ts (1)
417-418: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winKeep the close gate shut until the Retry reaches the host.
send('retry')starts an asynchronous RPC, butopenGate()runs before the test observes that the host received the Retry. The close operation can therefore complete first, allowing the test to pass without exercising Retry during blocked cleanup. Add a synchronization point that confirms the Retry reached the blocked path before opening the gate.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 2a3b7eec-d91c-4801-b5ae-422165bb93b5
📒 Files selected for processing (18)
src/main/native-chat/agent-session-wire/structured-agent-session-adapter.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.tssrc/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.tssrc/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-unproven-stop.test.tssrc/main/runtime/claude-structured-session-integration.test.tssrc/main/runtime/structured-agent-session-runtime-exit.test.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental re-review of fcbc590d6c, the merge of origin/main into this branch. That merge landed PR 2 / #22835 ("the conversation outlives its agent"), which removed the holds and made the delivery loop the only starter, so the merge re-carried this PR's behaviour onto that refactor rather than being a mechanical base-sync. Reviewed the current PR diff against main (32 files) with the merge message as the map.
- The sink-failure unproven end now runs the stop's own wind-down.
endUnprovenChildstillendProviderChilds the force-closed child ashost-stop/rootGone:falseand records the owed wind-down, but instead of its own settle + release it delegates tostopStructuredAgentSessionAgentUnderSerializethrough a newlifetimecontext. The release readsendedChildRootGone(false here), so the lease goesrecoveringand the wind-down'sresolve-recoverystep concludes it in the same step. No serialize re-entry: recovery takes only the store transaction andprocess.kill. - The stop wind-down composes #22835's "the row belongs to the conversation" with this PR's pieces. Status is published right after the child ends, the fence is published on release only when
released && cause === 'exit', and the newresolve-recoverystep publishes it when concluding aresolvedrecovery for an observed exit. - Deliveries keep their stop for a start seen to die. The host wires
stopAgentalongside #22835'sstartedForstart; the loop stillawaitsfail(...)then stops thestartingchild that had no start failure value.awaitStartednow returns the provider's raw failure object and the loop words it once. - The eviction deadline preserves best-effort.
withStructuredAgentSessionEvictionDeadlinespreads each step instead of copying onlyname, sodrain-published,settle-dead-generationandresolve-recoverystay non-fatal through the wrapper. The assertion moved from the deleted holds test into the eviction test, and #22835's retry tests now fail their stop by throwing (an unproven stop is no longer a failure), with a new same-tick conclusion case.
Verified locally: pnpm tc:node is clean, and the full src/main/native-chat/agent-session-wire/ suite passes (114 files, 904 tests), along with the touched runtime tests (claude-structured-session-integration, structured-agent-session-runtime-exit). The single prior pullfrog inline thread is resolved. The two open threads on this head are CodeRabbit's, which are theirs to resolve and were not re-raised here.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…es it After #22835 nothing starts an agent for a chat at rest, so the next attach that would re-derive a failed settlement may never come. The pending send and running turn then stayed open, and the pending send kept the conversation's handle from closing. The lease still moves; only the settlement stays owed, and the sweep's existing wind-down retry writes it.
…o answer Replaces the previous commit's owed flag with a derivation, as the open already does. With no child, no delivery and a send still pending, the sweep marks it in doubt on each tick until the write lands; nothing then pins the handle, the sweep closes it, and the reopen settles the rest in the exit's own words. No send is needed, and nothing new is stored.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental re-review of 9212fef938, the one commit since the prior pullfrog review (fcbc590d6c). It keeps a settlement that could not be written owed after the child ends, so an idle chat's next stop retries it instead of leaving a handed-over send pending until an attach that #22835 no longer triggers.
- A failed settlement stays owed across the stop.
stopStructuredAgentSessionAgentUnderSerializenow tracks whethersettleWorkactually wrote, andreleaseLeasesetssession.owesProviderChildWindDown = settled ? undefined : owedwhere it used to clear it unconditionally. The lease still moves — release and recovery run regardless of the settlement — so only the bookkeeping stays owed. - The idle sweep's existing retry writes it. The gate at
structured-agent-session-idle-sweep.ts:103(owesProviderChildWindDown !== undefined && !session.child && !queuedOrDelivering) already re-runsstopAgent; the retry's release is a no-op against the already-released record andresolveRecoveryreadsnot-applicableonce the lease has leftrecovering, so the second pass repeats only the settlement, with no send and no new child. - Test pins the no-send retry. The new case fails
markPendingSubmissionsUnknownonce, closes the chat, then ticks the sweep and asserts the submission readsunknown, the turninterrupted, the questioncancelled, the handle still open, and host activity unchanged.
Verified locally: the touched file is green (20 tests). Reverting the one-line assignment reds the new test on dispatchState (pending instead of unknown), so it exercises the fix rather than a reopen. The two remaining open threads on this head are CodeRabbit's, which are theirs to resolve; no pullfrog threads are open.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
… sweep in the wind-down header The sweep test only asserted the turn was no longer running, which a reopen that marked it completed would also pass. The eviction header still gave the next child's attach as the only re-derivation of failed bookkeeping; the idle sweep and the reopen after it now do too.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental re-review of 97a7f00767, the one commit pushed after my prior review of 9212fef938. It replaces that commit's "keep the whole wind-down owed" approach with a narrower step in the idle sweep.
- The owed-wind-down revert.
releaseLeasegoes back to clearingsession.owesProviderChildWindDownunconditionally, andsettleWorkno longer records whether it wrote. A failed settlement therefore no longer keeps the handle pinned by the retry gate. - The sweep marks a leftover send in doubt. New
markLeftoverStructuredAgentSessionSendsUnknownno-ops when nothing is pending, otherwise marks the session's pending sendsunknown(DISPATCH_DOUBT_PROVIDER_EXITED) and reports a write failure.tickUnderSerializeruns it for a childless session with nothing queued or delivering: a failure returns, a success lets the handle close, and the next open settles the turn and prompt throughsettleStaleStructuredAgentSessionState. - Tests cover the sequence. The rewritten case fails the mark twice (the stop's settlement, then the first sweep) and advances the host clock each sweep: the send stays
pendingafter sweep one, readsunknownafter sweep two, and the handle closes on sweep three, with the turn no longer running and the question cancelled on reopen.
Verified locally: the three touched suites pass (44 tests) and pnpm tc:node is clean. Neutralizing the new helper reds the new test on dispatchState. Two things I checked and cleared: the sweep's mark uses the post-release conversation fence while the old settlement used owed.fence, but markJournalPendingSubmissionsUnknown filters on dispatchState only and merely stamps the passed fence on resolved rows, so no submission can be skipped; and the gate cannot race a just-accepted send because sendStructuredAgentSessionTurn calls wakeDelivery inside the same serialized step as the accept. Deferring the turn/prompt settlement to the next open is the intended design here.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
… the sweep, with no send A provider exit whose settlement cannot be written leaves the send it was handed pending, which pinned the conversation's handle open. The sweep marks it in doubt, the handle closes, and the reopen tells the turn in the exit's own words, with no send and no relaunch.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental re-review of 5f6b8710aa, the one commit since my prior review of 97a7f00767. It is test-only: no production change.
- A case for the unexpected-exit trigger.
structured-agent-session-unproven-stop.test.tsgains a case (:714) alongside the existing host-stop sibling (:678). It leaves a handedpendingsend, a running turn and a pending question, then drives an adapterended(cause: 'unexpected-exit', reason'killed') with the exit settlement'smarkPendingSubmissionsUnknownforced to reject once, so the settlement aborts before writing. - The sweep lets the handle go, the reopen tells the exit. The send is asserted
pendingand the turnrunningafter the failed settlement; sweep one marks the sendunknown(handle stays open — the write resets the idle clock), sweep two closes the handle, and the reopen re-derives the turn asinterruptedwith the exit's own copy.acquireis unchanged, so no send or relaunch did it. - The import only names the outcome helper the new assertion reads.
The prior finding (the sink-failure settlement) remains resolved, and no pullfrog threads are open. Verified locally: the file passes (21 tests), and neutralizing markLeftoverStructuredAgentSessionSendsUnknown reds the new case at :744, so it exercises the re-derive path rather than a reopen.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
… stopped A stop that cannot prove its exit hands the lease to recovery, and the stop now concludes it in the same step. When recovery cannot verify the owner, it releases the lease without death evidence and sends no signal. The close treated that release as proof: worker-stop settled the dispatch, worker-release settled the resource (read back later as exited), and worktree removal counted the chat closed and deleted the checkout under a process that may still run. Only an exited session is stopped now. A lease released without proof still finishes the close, so its tab stays retired, but it reports not stopped: worker-stop and worker-release report unknown, and an ordinary worktree removal refuses with "could not confirm these closed" until forced.
An unproven release (no death evidence) reports release_unknown and never settles the resource; a release with death evidence still settles it.
There was a problem hiding this comment.
Important
A close that reports stopped: false for a lease released without proof of exit still retires the chat tab, and the worktree sweep now refuses the removal on exactly that lease — so the workspace survives while the user's chat tab is dropped. Details inline.
Reviewed changes
Incremental re-review of the two commits since the prior pullfrog review (5f6b8710aa). Prior inline feedback (the sink-failure settlement) remains resolved; no pullfrog threads are open.
stoppedfrom a close now means a proven exit.closeStructuredAgentSessionChildreturnsstopped: false(with a reason) when the post-close observation is notexited, even though the lease is released and the close already retired the tab.structuredSessionCloseSettledkeeps its broader "nothing left to act on" meaning for finishing the close.- Worktree teardown refuses over a released-unproven lease. The sweep's re-read went from
status === 'exited' || structuredSessionCloseSettled(...)tostatus === 'exited'; a lease recovery released without death evidence isunverifiableand now lands inunstopped, so an ordinary (non-force) removal refuses rather than counting the session closed. - A worker release settles only on death evidence.
stopStructuredWorkerForReleasealready maps!stop.stoppedtorelease_unknown; with the above, a released-unproven lease reportsrelease_unknowninstead of settling with no evidence. - Tests pin the new verdicts. The release case asserts
stopped: falsewhile the tab retirement still runs, andsettleWorkerTerminalReleaseonly on death evidence; the teardown case asserts the refusal; the close and at-rest cases assert not-stopped with the leasereleased/no evidence.
ℹ️ Nitpicks
- The PR body's "What callers that need proof now see" still says a worktree removal reads an unproven chat as
unverifiablerather thanliveand settles in-step; with this commit an ordinary removal now refuses over that lease (force still proceeds). Worth correcting so the behavior change is visible in the writeup.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| // Only past the proof, and structurally unable to throw: the session's chat tab is retired from | ||
| // the live snapshot, which `setSessionTabVisibility(false)` above does not do. | ||
| retireSettledStructuredWorkerTab(sessionId, options.runtime) | ||
| if (observeStructuredWorker({ sessionId }).status !== 'exited') { |
There was a problem hiding this comment.
A close that reports stopped: false for a lease recovery released without proof of exit still retired the durable tab (the hide at line 77 was never put back, because the rollback at line 152 is keyed on structuredSessionCloseSettled, which is true for the same released lease) and retired the live snapshot tab at line 104. closeStructuredSessionsForWorktree now refuses an ordinary removal on exactly this lease, so the workspace survives while the user's chat tab is gone — the loss restoreTabOnUnprovenClose exists to undo. I confirmed the durable index is dropped (visible empty, stopped: false) with a throwaway test against this function.
Technical details
# A not-stopped close still drops the chat tab, and the worktree sweep refuses over the same lease it never un-dropped
## Affected sites
- `src/main/runtime/structured-agent-session-close.ts:104-112` — `retireSettledStructuredWorkerTab` runs before the new not-exited check, and the returned `stopped: false` no longer implies the caller will proceed.
- `src/main/runtime/structured-agent-session-close.ts:152` — `restorePersistedTabVisibility` skips the restore whenever `structuredSessionCloseSettled` is true, which is exactly the `unverifiable` + `released` lease this branch returns on.
- `src/main/runtime/structured-session-worktree-teardown.ts:277-293` — a `mayRefuse` removal passes `restoreTabOnUnprovenClose: true`, then refuses when the re-read status is not `exited`; nothing there restores the tab the close dropped.
- `src/main/runtime/structured-worker-authority.ts:134-141` — `structuredSessionCloseSettled` conflates "the close has nothing left to act on" with "safe to drop the tab".
## Required outcome
- When a removal refuses, the chat tab must survive it: do not retire the durable/live tab for a `stopped: false` close, or have the sweep restore the tab for a session it pushed to `unstopped`.
- Decide whether the orchestration release path (which reports `release_unknown` and stays retryable) should also keep the tab, or intentionally drop it.
## Suggested approach (optional)
- Move the `retireSettledStructuredWorkerTab` call behind the `status === 'exited'` check so a not-stopped close never retires, or make `closeStructuredSessionsForWorktree` restore the durable reference (and leave the snapshot tab) in its `unstopped` branch.
## Open questions for the human
- Is retiring the chat of a released-but-unproven process intended even when the removal refuses and the workspace stays?
Scope: which chat this affects
experimentalStructuredNativeChatsetting, which is off by default (src/shared/default-global-settings.ts:137).ELI5
Orca kept treating an agent as the chat's agent after it already knew that agent could no longer take messages. It did this because it waited to prove the agent's process had exited before letting go. When that proof never came, the chat was stuck: every message went to a dead connection. Now Orca lets go of the agent as soon as it can't take messages. The only thing still waiting on the proof is Orca's record of which process owns the chat, and Orca settles that record right away by checking the recorded process and stopping it if it is still running.
What a user sees:
Terms used below
What Changed
Before
After
Why
Alternatives considered
Differences from the common pattern
Known weaknesses
Linked Issue
Part of the native-chat "a chat never gets stuck" series.
Visual Proof
Live desktop app on a separate Mac: an isolated dev instance with a hidden window, driven over the Chrome DevTools Protocol, running a structured Claude chat with real Claude Code. Before = this branch with its production changes removed. After =
5f972a4d43; the later head only merges main. Each run started from a fresh profile.QA-only levers, identical in both builds, each off unless a flag file exists:
claudewrapper that makes a send-started Claude exit while starting and leave behind a child process that ignores SIGTERM, so the shutdown sequence stays open.Normal behaviour is unchanged (after): a Stop mid-turn ends the turn cleanly and the same Claude answers the next message; after a normal idle release the next message starts a new Claude and is answered. Neither shows an "unconfirmed" marker on earlier messages.
Testing
New tests run against a real host, record store and history journal, with a fake agent adapter and an injected process check (
structured-agent-session-unproven-stop.test.ts, plus cases in the recovery, eviction and Claude integration tests):Each production change was removed in turn and its tests went red for that reason: the stop-and-recover step (12 fail), the failed-attach recovery (3 fail), the throwing-check mapping (4 fail), the loop's stop on a seen start death, the Claude exit-map read, the settlement on each end, the best-effort cleanup, the new-owner "unconfirmed" mark and its reconnect guard, the fence notice, and the reason cap.
Also green: the native-chat host suites, the history journal suites, the Claude and Codex structured-session suites, the cross-version send cases,
tsc(node and web),oxlint,oxfmtand the changed-code quality gate.Notes
unverifiablerather thanlive, and only until recovery settles the record, which now happens in the same step.Follow-ups
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.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)