Repository navigation
fix(native-chat): the agent's exit ends its record, and an unconfirmed stop is joined instead of held - #24862
Conversation
…se, and bookkeeping after it never reads as unproven - Both connections report the root process's exit once, with `expected` set when a close had begun. A close that came back unproven and whose root exits later is finished by the adapter, and its end reaches the host like any other. - A Claude close whose resume-point write fails after the exit was proven, and a Codex close whose terminal row is refused, now end the session and report the failure, instead of keeping a dead child indexed as if its exit were unproven. - A Codex close whose forced tree kill can't prove the descendants gone but saw the root exit reports the descendants and counts the root exit. - Every child exit with an identity, expected or not, is forwarded to the host.
…p is the child's own close, which everyone joins - The host keeps no stored "stop still owed" record any more. A stop begins the child's close (`child.close`), which lives on the child and ends with it. A second Stop, the idle reaper, quit, a send and an option/answer/goal/rewind all join that close instead of retrying a separate obligation. - A caller waits on the close only as long as the step deadline; the close itself is never abandoned. A proof that lands after every caller stopped waiting reaches the host as the adapter's report of that exit, which ends the record through the same handler. - Once the exit is proven, draining, settling, the lease release and the adapter's acknowledgement are each attempted and reported on failure; none keeps the child on record. A start, and the handle's close, write a release that failed from this host's proof of that exit, so a failed write never refuses a send. - A start that meets a close still unverifiable is refused with `previousExitUnverifiable`, so the queued message is rejected with a send-again reason; nothing is held and nothing starts beside the old process. - The idle sweep goes back to idle reaping only. - Removes #24333's retry entry points, the wait row and its hold rule, the ask/failure cursors on the stored record, and the stop's own wake. Tests replace the #24333 unproven-stop test: a send joining an unproven close and an in-flight one, a late proof past the caller's bound, a root exiting after its close gave up, a proven exit whose resume-point write and lease release both failed, an unverifiable close rejecting the send and refusing an option change, a surviving descendant, quit and the idle reaper; and Codex's unverifiable, late-exit and joined-close cases.
… unverifiable says so, and to send again The start failure for a refusal with reason `previousExitUnverifiable` reads "Orca couldn't confirm Claude's previous process ended. Send your message to try again." instead of "Claude couldn't restart." The status-row kind and the refusal reason stay in the shared lists for rows and hosts that still carry them; the catalogs keep one sentence for both.
…t follows it is logged - A Claude close resolves as soon as the root's exit is proven: the session ends and its `ended` report goes out then. Saving the resume point runs afterwards and a failure is logged, so a slow or hung write never reads as an unproven exit or keeps a dead child on record. - A root that exits after its close came back unproven finishes that close through the same path as any close, so the session's child work is published as ended (background tasks and subagents no longer stay shown running for a dead agent), and a failure there is logged. - Codex logs a refused final row, and reports a root exit whose forced tree kill could not prove the rest of the tree gone the way Claude does, so the host logs it and blocks nothing. - Both adapters take the host's logger for this bookkeeping.
…ts on the adapter's own close - One exit handler (`structured-agent-session-child-exit`) ends a child's record for an exit expected or not. `expected` only changes what the chat is told: the stop's cause, its end at the stop's ask, the settlement id, and no crash outcome row. The lease release keeps the exit's evidence; the handoff guard, lifecycle barrier, sink release and adapter acknowledgement apply to both. A Claude journal-sink failure ends in the same step as its stop, as Orca's own fault. - Joining a close is asking the adapter, whose close is memoized while it runs and bounded by its own kill escalation; the host keeps no attempt of its own and no 10 s caller bound. An ask after a close came back unproven runs the stop again. - A close's end is stamped where its stop was asked for (a repeated ask moves it), so the closed chat and failed start checks order a message accepted meanwhile after it. - A start refused because the old exit is unverifiable rejects what was queued in the same step. - The end of a close the host asked for no longer waits on the cross-session recovery chain. - The kill no longer waits for the stop event's write; the journal writes rows in order.
…f its reason A crash's reason can carry kilobytes of the provider's stderr, and a lease whose death detail is over 512 characters fails the store's own check. The exit handler cut it, but the release a start or the chat handle's close re-derives did not, so after a crash whose own release failed every message was refused as not resumable until restart. The record's builder now cuts the detail to the record's bound, so no writer can hand it one too long.
…dy wrote Once the root's exit is proven, the close still waited for the SDK's output reader to end. Something outside the process tree that holds the output open would keep that close, and every send, Stop and quit joining it, waiting with no bound. The wait is now bounded; past it the close resolves as proven and the open output is logged.
…a closed it for The connection reports the app-server's exit inside the close that ends it, so that report ended every Codex close and replaced the close's own reason (for example, a provider frame that could not be recorded) with the connection's stderr text in the ended record and the lease's exit evidence. The session now records Orca's close with its reason, and the exit it ends keeps that reason. The test connection reports its exit inside close the way the real one does.
Every exit now wakes delivery, and teardown drained exit recovery before it stopped delivery, so an exit settled in that window could start a fresh agent that teardown then killed. Teardown stops delivery first; queued messages wait for the next launch.
…ller's wait The caller's bounded wait was removed; the comment describes the close as it is now.
…ext ask kills again When a close's kill leaves the agent's root running, the host now logs it. Tests pin what a later ask does: each connection runs its whole stop again (Codex sends SIGKILL a second time), refuses input meanwhile, and proves the exit once the kill takes.
…dn't stop it
The host reaches an unverifiable verdict only after its own kill left the agent's root running, on
the machine that runs the agent, so the sentence now says that: "Orca couldn't stop {agent}'s
previous process." The refusal reason, failure kind and wire shapes are unchanged. The host test
also checks the failed kill is logged.
…ved the kill The host's close runs where the agent runs, so lost contact never yields this verdict; the comment no longer says it does.
…er met it The log added at the close fired beside a Stop's own failure report for the same event. A stop still reports it through its failure; a send or option change refused over it now logs it at the refusal, the only place it is otherwise invisible.
…p-exit-ends-record # Conflicts: # src/main/codex/codex-app-server-connection.test.ts
Live QA (Electron)Tested Claude was not run (no Claude CLI on this laptop, and no stub was used). The survived-kill refusal case was not run.
Scenario 2, turn still running, just before Stop: Scenario 2, both replies in order after the two sends: Scenario 3, turn still running, just before quit: Scenario 4, same chat after relaunch, with the earlier turns still there: Scenario 4, the next send answered: |
…p-exit-ends-record # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
…ove and retries its kill
|
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 configuration
📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (6)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review. 📝 WalkthroughWalkthroughClaude and Codex connections now report exits observed during close, and Claude limits post-exit reader draining. Session close handling records requested-close details and finalizes proven exits despite persistence or callback failures. The structured-session host stores close state on provider children, joins unverified closes, and processes expected and unexpected exits through shared settlement. Runtime delivery and teardown ordering, failure messages, and death-evidence detail limits are also updated. Priority: ➖ Normal Merge Risk: 🟡 Moderate · up to The selected changes are translation updates and add no new risk. A previously raised concern remains open: the Claude session close no longer waits for its handle write, so a quick re-acquisition could resume from a stale point. Resolve or accept that before merging. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to Closing a chat can now finish before its final state is saved. A delayed save can adopt a replacement process's ownership identity and affect newer session metadata. Process-exit checks and permission protections remain in place; no broader account or privilege takeover was demonstrated. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 46.34% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 82 functions across 57 files. (6 skipped: 6 unsupported.)
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: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
974ad758-b15c-40db-ba02-c51e5e2d8472
📒 Files selected for processing (81)
src/main/claude/claude-stream-json-connection-close.test.tssrc/main/claude/claude-stream-json-connection.tssrc/main/claude/claude-structured-requested-stop.test.tssrc/main/claude/claude-structured-session-acquisition.tssrc/main/claude/claude-structured-session-adapter.tssrc/main/claude/claude-structured-session-close.test.tssrc/main/claude/claude-structured-session-close.tssrc/main/claude/claude-structured-session-recovery.test.tssrc/main/claude/claude-structured-session-state.tssrc/main/codex/codex-app-server-connection-types.tssrc/main/codex/codex-app-server-connection.test.tssrc/main/codex/codex-app-server-connection.tssrc/main/codex/codex-close-turn-end.test.tssrc/main/codex/codex-requested-close-turn-timing.test.tssrc/main/codex/codex-structured-session-acquire.tssrc/main/codex/codex-structured-session-adapter-fixture.tssrc/main/codex/codex-structured-session-adapter.tssrc/main/codex/codex-structured-session-close.test.tssrc/main/codex/codex-structured-session-close.tssrc/main/codex/codex-structured-session-options-catalog.test.tssrc/main/codex/codex-structured-session-options.test.tssrc/main/codex/codex-structured-session-state.tssrc/main/codex/codex-structured-session-teardown.tssrc/main/native-chat/agent-session-journal/journal-host-database.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-agent-start.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-context.tssrc/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-child-close.tssrc/main/native-chat/agent-session-wire/structured-agent-session-child-exit.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.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-host-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-types.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.tssrc/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.tssrc/main/native-chat/agent-session-wire/structured-agent-session-lease-release.tssrc/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.tssrc/main/native-chat/agent-session-wire/structured-agent-session-provider-child.tssrc/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.tssrc/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.tssrc/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.tssrc/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.tssrc/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.tssrc/main/runtime/agent-session-surface-release-transition.tssrc/main/runtime/structured-agent-session-lifecycle-delivery.test.tssrc/main/runtime/structured-agent-session-lifecycle-delivery.tssrc/main/runtime/structured-agent-session-runtime-teardown.test.tssrc/main/runtime/structured-agent-session-runtime-teardown.tssrc/main/runtime/structured-agent-session-runtime.tssrc/main/runtime/structured-claude-runtime-adapter.tssrc/renderer/src/i18n/en-runtime-required.jsonsrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/fr.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/shared/agent-session-failure-copy.tssrc/shared/agent-session-failure-words.tssrc/shared/agent-session-failure.tssrc/shared/agent-session-record.tssrc/shared/agent-session-refusal-details.ts
💤 Files with no reviewable changes (9)
- src/main/codex/codex-structured-session-options-catalog.test.ts
- src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts
- src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts
- src/main/codex/codex-structured-session-options.test.ts
- src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts
- src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts
- src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts
- src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
- src/main/codex/codex-close-turn-end.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.
| if (callbackThrew) { | ||
| throw callbackError | ||
| } | ||
| session.closePersistence ??= persistClosedClaudeSession(input, session) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
rg -nP -C4 'recordAgentSessionProviderHandle|reviseAgentSessionClaudeResumePoint' src/main
rg -nP -C6 'closePersistence' src/main/claude
rg -nP -C6 'resumeLeafUuid|providerHandleChain' src/main/claude/claude-structured-launch*.ts src/main/runtime/*claude* 2>/dev/null | head -120Repository: stablyai/orca
Length of output: 27864
🏁 Script executed:
#!/bin/bash
set -o pipefail
printf '%s\n' '--- reviewed close-file diff ---'
git diff --unified=8 2165558c9dc507cc9538a58ff2418b357d46f155 1bf8f29e6543a59e15540963643bf2ed787edac5 -- src/main/claude/claude-structured-session-close.ts
printf '%s\n' '--- close implementation ---'
sed -n '1,230p' src/main/claude/claude-structured-session-close.ts
printf '%s\n' '--- turn-end and close persistence bindings ---'
rg -n -C8 'persistClosedClaudeSession|settledClaudeTurnEndLeaf|resumePointWrite|closePersistence' src/main/claude/claude-structured-session-close.ts src/main/claude/claude-structured-session-state.ts src/main/claude/claude-structured-session-adapter.ts src/main/runtime/structured-claude-runtime-adapter.ts
printf '%s\n' '--- launch resolution and lifecycle call sites ---'
rg -n -C5 'createClaudeStructuredLaunchResolver|resolveLaunch|finishClaudeCloseAfterExit|closeClaudePublishedSessionForDeps|closeClaudeSession\\(' src/main/claude src/main/runtime src/main/native-chat
printf '%s\n' '--- record transition guards ---'
sed -n '1,135p' src/main/runtime/agent-session-provider-handle-transition.tsRepository: stablyai/orca
Length of output: 28023
🏁 Script executed:
#!/bin/bash
set -o pipefail
printf '%s\n' '--- launch and close call sites ---'
rg -n -C6 -e 'createClaudeStructuredLaunchResolver' -e 'resolveLaunch' -e 'finishClaudeCloseAfterExit' -e 'closeClaudePublishedSessionForDeps' -e 'closeClaudeSession' src/main/claude src/main/runtime src/main/native-chat
printf '%s\n' '--- launch resolver implementation ---'
fd -i 'claude-structured-launch*' src/main/claude --type f --exec sh -c 'echo --- $1; sed -n \"1,280p\" \"$1\"' sh {}
printf '%s\n' '--- runtime persistence callbacks ---'
sed -n '70,135p' src/main/runtime/structured-claude-runtime-adapter.ts
printf '%s\n' '--- lifecycle ended handlers and lease/release flow ---'
rg -n -C10 -e \"cause: 'requested-close'\" -e \"type === 'ended'\" -e \"event.type === 'ended'\" -e 'runtimeFence' -e 'release.*lease' src/main | head -260Repository: stablyai/orca
Length of output: 42142
🏁 Script executed:
#!/bin/bash
set -o pipefail
printf '%s\n' '--- matching production files ---'
rg -l 'createClaudeStructuredLaunchResolver' src/main --glob '!*.test.ts'
rg -l \"requested-close|acquisitionGeneration\" src/main --glob '!*.test.ts'
rg -l 'release.*runtimeFence|runtimeFence.*release|claimStatus.*released|releaseLease' src/main --glob '!*.test.ts'
printf '%s\n' '--- launch resolver source ---'
for f in $(rg -l 'createClaudeStructuredLaunchResolver' src/main --glob '!*.test.ts'); do
echo "--- $f"
sed -n '1,300p' "$f"
done
printf '%s\n' '--- ended consumer source context ---'
for f in $(rg -l \"requested-close|acquisitionGeneration\" src/main --glob '!*.test.ts'); do
echo "--- $f"
rg -n -C18 \"requested-close|acquisitionGeneration|type: 'ended'|event.type === 'ended'\" "$f"
done
printf '%s\n' '--- lease release matches ---'
for f in $(rg -l 'release.*runtimeFence|runtimeFence.*release|claimStatus.*released|releaseLease' src/main --glob '!*.test.ts'); do
echo "--- $f"
rg -n -C12 'release.*runtimeFence|runtimeFence.*release|claimStatus.*released|releaseLease' "$f"
doneRepository: stablyai/orca
Length of output: 41910
🏁 Script executed:
#!/bin/bash
set -o pipefail
printf '%s\n' '--- ended child release ---'
sed -n '70,165p' src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts
printf '%s\n' '--- structured start flow ---'
sed -n '95,205p' src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts
printf '%s\n' '--- turn-end persistence implementation ---'
rg -n -C12 -e 'resumePointWrite' -e 'persistResumePoint' -e 'settledClaudeTurnEndLeaf' src/main/claude --glob '!*.test.ts'
printf '%s\n' '--- launch resolver return fields ---'
sed -n '210,310p' src/main/claude/claude-structured-launch-resolution.ts
printf '%s\n' '--- transition persistence implementation ---'
sed -n '90,130p' src/main/runtime/structured-claude-runtime-adapter.tsRepository: stablyai/orca
Length of output: 31922
🏁 Script executed:
#!/bin/bash
set -o pipefail
printf '%s\n' '--- numbered decisive ranges ---'
rg -n -C5 'session.closePersistence \?\?=|persistClosedClaudeSession|type: .ended.|structuredClaudeLifecycleEvent|releaseLeaseOfEndedStructuredAgentSessionChild|startStructuredAgentSessionAgent|const record = deps.store.getRecord|const head = agentSessionProviderHandleChainHead|resumePointWrite|settledClaudeTurnEndLeaf|persistHandle: async|persistResumePoint: async' \
src/main/claude/claude-structured-session-close.ts \
src/main/claude/claude-structured-session-exit-lifecycle.ts \
src/main/claude/claude-structured-resume-point.ts \
src/main/claude/claude-structured-session-state.ts \
src/main/claude/claude-structured-launch-resolution.ts \
src/main/runtime/structured-claude-runtime-adapter.ts \
src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts \
src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.tsRepository: stablyai/orca
Length of output: 27896
Order close persistence before the next Claude start.
finalizeClaudePublishedSession emits ended and starts persistClosedClaudeSession without awaiting it. The next start can release the ended child’s lease and read the durable provider handle while that persistence is still pending. If the turn-end resume write failed or did not cover the final leaf, the new launch can read a stale cursor. The later handle write can then use the new lease fence or fail, and the failure is only logged.
Ensure that the next acquisition awaits a bounded closePersistence barrier before releasing the lease or resolving its launch from the durable record.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- The stop is the child's close.
owesProviderChildWindDownand the wind-down wait row are deleted; a stop writeschild.closeon the provider child, and every later Stop, start, send, model/effort change, card answer, goal, rewind, the idle reaper, and quit join that same close throughstructured-agent-session-child-close.ts. - One exit handler.
structured-agent-session-unexpected-exit.tsis replaced bystructured-agent-session-child-exit.ts: expected closes and crashes share it, every post-exit step is attempted and logged rather than gating, andwakeDeliveryfires infinally. - Lease release is re-derived. A start and the conversation's close re-write the release from the host's in-memory
lastEndedChildproof when the exit handler's write failed, and the death detail is bounded toMAX_AGENT_SESSION_DEATH_DETAIL_CHARS. - Connections report
expectedexits. Both connections report a root exit during a close as that close's end; Claude bounds the proven-close output drain at 2 s; Codex records the close's own reason and reports an unproven process tree. - Copy.
previousExitUnverifiablenow says "Orca couldn't stop {{agent}}'s previous process." across the shared copy and the 7 locale catalogs, with the failure kind and wire shapes unchanged.
ℹ️ Quit no longer retries a lease release that failed after a proven exit
evictOwnedStructuredAgentSessions now selects sessions by session.child !== null, so a session whose child was already proven gone but whose exit-handler lease release failed is skipped at quit; it closes the journal and drops the session without re-deriving the release. This looks deliberate given the teardown comment already accepts an unreleased lease across quit, but it is a behavior change from the removed owedProviderChildWindDown, which used to outlive the child and be retried by quit.
Technical details
# Quit-time lease release re-derivation narrowed
## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts:193-195` — filter changed from `owedProviderChildWindDown(session) !== undefined` to `session.child !== null`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts:177-190` — the exit handler's release is best-effort and logged; on failure `lastEndedChild.rootGone` stays true and the lease stays live.
- `src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts:124-146` — teardown closes journals for non-retained sessions without calling `releaseLeaseOfEndedStructuredAgentSessionChild`.
## Context / required outcome
- Before this PR, a proven exit with a failed release left `owesProviderChildWindDown` set, so quit included the session and retried the stop (and its release).
- After this PR, only a live child keeps a session in `ownedSessionIds`; the only re-derivation points are `startStructuredAgentSessionAgent` and `closeConversation`, neither of which quit runs. A clean quit therefore leaves a `live` lease for a proven-dead owner, which next-launch reconciliation settles.
- This is consistent with the codebase's "restart reconciles a deferred lease" policy, so no change is requested — confirm the intent is to defer this to recovery rather than retry it at quit.
## Open questions for the human
- Is deferring a post-exit release failure that survives to quit to next-launch recovery the intended trade-off, or should quit also sweep `lastEndedChild` leases?deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…p-exit-ends-record # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts # src/renderer/src/i18n/en-runtime-required.json # src/renderer/src/i18n/locales/en.json # src/renderer/src/i18n/locales/es.json # src/renderer/src/i18n/locales/fr.json # src/renderer/src/i18n/locales/ja.json # src/renderer/src/i18n/locales/ko.json # src/renderer/src/i18n/locales/zh.json # src/shared/agent-session-failure-copy.ts
…inly
The rejection now reads "Couldn't stop {{agent}} from before. Send your message again to try once more."
This kind has its own send-again step; every other failure keeps "Send your message to try again."
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- The refusal copy is plainer.
previousExitUnverifiablenow reads "Couldn't stop {{agent}} from before." instead of "Orca couldn't stop {{agent}}'s previous process.", across the shared copy and all 6 locale catalogs. - That refusal gets its own send-again step. A new
sendAgainToTryOnceMorepiece ("Send your message again to try once more.") is registered inAGENT_SESSION_FAILURE_COPY, the desktopPIECESmap,en-runtime-required.json, and every locale;startRetrygained asendAgainargument, and only thepreviousExitUnverifiablerefusal path passes it, so every other failure keeps "Send your message to try again." - Tests pin the new wording. A new
agent-session-failure-wordscase covers the default,/command, and Retry-control sentences, and the two host-level Stop tests now assert the new rejection text.
ℹ️ Nitpicks
- The PR description still quotes the copy this commit replaces: "Orca couldn't stop Claude's previous process. Send your message to try again." and "its sentence now says what happened: "Orca couldn't stop {{agent}}'s previous process."". The shipped text is now "Couldn't stop {{agent}} from before. Send your message again to try once more.", so those two passages read stale.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Built on the one-exit-handler, close-on-the-child model of #24862. - Claude: an Orca fault whose close cannot prove the child gone reports `end-unproven` instead of staying silent, and the child's later exit (now reported inside that close) settles the exit the adapter kept. - Codex: a forced close that cannot prove the exit reports `end-unproven` for the window before the exit. - Sink failure: an unproven or failing stop reports the end instead of returning silently. - The host keeps such a child on record, closing, with the reported reason, failure and whether work was in flight; every later send, start or Stop joins that close. The exit that ends the record settles as that end (cause exit, the fault's row when work was interrupted); a Stop or close asked for since keeps its own cause and the fault's row. - Dispatch and /compact to a session the adapter no longer holds answer `rejected` (never a throw that reads as delivery unknown), keeping a failed start's wording; Codex says the agent stopped only when its end was seen. - `end-unproven` is handled on its chat's own lane, off the exit chain.
…p-exit-ends-record # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Since the prior review at 8fb8aaf, the only new commit is a merge of origin/main (a154562) into the PR branch. It reconciles main's #25043 "one explanation for a turn cut short" with this PR's single-exit-handler design and extracts two helpers; no new behavioral defect found, and the affected host and adapter suites pass.
child-exit.tscarries #25043's exit-row identity. The only change #25043 made to the deletedunexpected-exit.tswas switching the settlement id toPROVIDER_EXIT_ROW_PREFIX;structured-agent-session-child-exit.tsnow does the same, sonative-chat-cut-turn-notice.ts's dedup still recognizes the exit row.sendPreparationkeeps both sides. The PR'sfinishOwedStop→joinChildClosechange survives main's newadmit/replayledger signature instructured-agent-session-send-preparation.ts.freeingAskerextracted. Main's private adapter helper moved tosettleClaudePromptFreeingChildinclaude-structured-prompt-ownership.ts, with the adapter's newaskerbinding it; the logic is equivalent to main's.stopBackgroundTasksextracted. The adapter's inline "stop while still current, then publish child work" body moved tostopCurrentClaudeBackgroundTasks; behavior-identical.- Merge-resolved tests and copy.
close-verdictandconversation-closetests adopted main's newer assertions (label,ONE_NOTICE,appendLifecycleBatch.rejectsQueued), and the8fb8aafcopy survived in every locale catalog.
No dropped side: every main-touched PR file appears in the merge's combined diff, and no dangling references to the deleted unexpected-exit.ts, owesProviderChildWindDown, or finishOwedStop remain. Ran the 4 merge-affected host test files (65 tests) and the 3 Claude adapter/helper files (63 tests) — all pass.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…p-exit-ends-record
…p-exit-ends-record # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
|
This run was cancelled 🛑 The workflow was cancelled before completion. Please check the link below for details. |
- claude-unproven-stop-send.test.ts: deleted on main (replaced by claude-stop-exit-ends-record); take the deletion. The replacement builds its host with claudeAndCodexAgents(adapter), as the deleted test did here. - codex-stop-row.test.ts: this branch's agents dep + main's recording logger.
structured-agent-session-runtime.ts: this branch builds each agent's adapter from its registration; main #24862 gave the Codex and Claude adapters the logger and made Codex hand the host every exit (expected or not), so those two changes move into createCodexAdapter / createClaudeAdapter in structured-agent-runtime-registrations.ts. The record store's open() names its rows so the merged file stays under max-lines.
This branch carried an earlier copy of #24862's commits; main now has its final, squashed form. Main's #24862 wins for #24862's code; this branch's own changes (shared managed provider process, close result, stderr tail) are re-applied on top: - host-lifetime.ts, host-types.ts, stop-settle-binding.test.ts: main's version. Final #24862 already holds this branch's earlier merge resolution (the close's recorded settle) and its later fix (a repeated close reopens the settle), with its own test for it. - claude-stream-json-connection.ts, codex-app-server-connection.ts: this branch's managed-process version; main's side of these files equals the earlier #24862 copy this branch replaced (checked with a three-way merge over old-#24862-on-main as base). Drops a duplicate processTreeUnproven getter the plain merge left outside the conflict.
…es the exit A session-ending Stop whose child's end failed revises its note to 'unconfirmed'. Since main's #24862, the next Stop joins that close rather than stopping again, and wrote no note, so a close that then proved the exit left 'unconfirmed' under a turn that ended. The close now carries the note it settles, and its proven exit revises it to 'Cancellation requested.'. A join that fails again leaves it unconfirmed.
…e goes to the resumed child, the next waits for its echo Covers the case main's #24862 retired with its unproven-stop test: the first message after an unproven close joins it and reaches the resumed child, and a second waits for that message's turn to open.
…e turn actually ends (#24369) * test(native-chat): a Stop over a card sent now into the running turn keeps it paused Red on main: Codex's turn end withdraws the steered hand-off and the queue sends the card again as a new host turn, with no pause recorded. * fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered A card sent now into the running turn was still pending when Stop judged the pause, so nothing was recorded; the interrupt then withdrew the hand-off, the card went back to waiting unpaused, and the queue sent it again as a new turn. The pause now counts a hand-off that may still return to waiting, judged with the appended row applied, so a withdrawal lands under the pause and an acceptance retires it in that same write. Codex and Claude both hit it. * test(native-chat): the Claude re-send case fails on its diff, inside the test's budget * test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it The provider's answer, the provider dying, the chat closing, a restart, a withdrawal still owed at open, and a /clear (refused until the hand-off ends, then carrying every waiting card paused 'cleared'); each ends with the queue sending again. * fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card * refactor(native-chat): derive the queue's pause from Stop and Resume journal rows Stop now appends one journal row where it takes effect, before the interrupt, whatever the queue holds; Resume appends its own. The pause is a pure function of the fold: the latest Stop with no later Resume and no later accepted turn a person asked for. A /clear's carried cards name their source, which is the replacement's 'cleared' pause. Host-origin turns never lift either. One predicate decides which cards a pause holds; by default every waiting card without a hold of its own, including one queued after the Stop. The drain's consume re-judges it inside its own transaction. The rows are tombstones of an id no item takes, carrying the mark: a released host reads an unknown row kind as corruption and truncates the journal there. Deletes the stored pause (recordPause, the retire hook on every appended row, the settle-before-record step, mayReturnToWaiting and its row overlay) and the tests that only proved it retires. The queued_message_pauses table stays in the schema, unread and unwritten, for downgrade safety. * fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones A Stop's pause now holds only the cards queued before its row, plus a steer it withdrew, which returns to its own place. Each card records the journal position it was queued at, and the one hold rule compares that with the Stop row. A card queued after the Stop is a new instruction: it sends as usual, but the drain still stops at the first held card, so it never overtakes them. /clear's pause holds the cards it carried. Holding every card again is a one-line switch in that rule. * fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction The drain's pick and its consume now read one function, nextSendableQueuedCard, so a Stop row that lands between them holds a newer card behind an older held one exactly as the pick would. Notes why Stop and Resume ride a tombstone row. * fix(native-chat): stop creating the unused queue pause table The queue's pause is derived from journal rows, so nothing reads or writes queued_message_pauses. It was still created on every open "for downgrade safety", but an older build creates it itself when it opens the database, so the table only sat empty in every new database. The tests now pin that no pause table exists. * fix(native-chat): a Stop's pause never hides the restart pause A Stop holds only the cards queued before it. The pause derivation still returned the Stop alone whenever it was in force, so the restart pause was never considered: a card queued after the Stop, written by a host process that has since exited, sent by itself after Orca restarted, with no pause header and no Resume. A /clear pause that held nothing could hide it the same way. Every pause in force is now derived. A card is held if any of them holds it, and it names the first that does. The drain's pick, the consume transaction's re-check and the published header all read that one rule; the header names the pause holding the first card Resume would send. * test(native-chat): pin the Stop's no-resend, lift and held-card rules - The Claude and Codex Stop-withdraws-a-steer tests checked "not sent again" at one instant, before a queue ignoring the pause re-sends. They now wait for the stopped turn to end and re-check after a quiet window. - The deleted-card test read a card queued after the Stop, which sends whether or not a person's turn lifts it; it now reads the Stop's pause before and after that turn. - Unit cases pin that a Stop holds a card with no recorded position and one queued before a rewind. * refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller The Stop row that paused the queue becomes the general Stop event { reason, turnId?, at, caller? }, whose reason is the host's existing stop cause. It still rides a tombstone of a host-only id (a released host deletes the journal from the first unknown row kind), and Resume keeps its own marker on its own id. Only a person's Stop (reason user-stop) pauses the queue. * test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event * test(native-chat): pin that Stop and Resume rows never reach apps or count as history * test(native-chat): only a person's Stop event pauses the queue * test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop Through the real host: the event names the turn and who asked and is in the journal when the interrupt reaches the agent; at an agent still starting it is there before the start is ended and holds a card queued before it; an idle Stop writes one only when it withdrew a send; and the queue's claim re-judges a pause that landed after its pick. * test(native-chat): a card held at a starting agent is checked before the Stop's timing Also says precisely what the claim's in-transaction pause check defends against: the Stop and the drain share one serialized lane. * test(native-chat): a released build keeps and folds a journal holding Stop events Replays this build's rows from the released build's own journal database: every row is kept, the history after the Stop still folds, and an older client is sent only removed ids no item uses. * style(native-chat): format the Stop event changes * test(native-chat): type the released build's exports through one checked helper * fix(native-chat): the Stop/Resume row guard narrows to those tombstones only * test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade The Stop-event downgrade test ran in no CI lane: unit shards exclude the cross-version folder, and the cross-version lane runs a fixed file list that did not name it. It is now on that list. Its only case replayed the rows into a release's own fresh database, because that release cannot open the current host database. A second case opens the journal this build wrote with a main build that shares the database: it opens writable, keeps every row, appends, and this build then reopens it with the person's Stop still pausing the queue. * fix(native-chat): a Stop that stops nothing new writes no Stop event A Stop reaching a running agent wrote a Stop event on every press. Two presses before the first interrupt landed wrote two events, so a card queued between them counted as before the latest Stop and was held, though a card queued after a Stop should send normally. A Stop naming a turn that had already ended, as a phone sends late, also wrote an event for a turn it never stopped. It now writes one only when it withdrew a queued send, or stops something no event records yet: not a turn the journal no longer runs, and not the live turn a Stop still in force already names, unless a card was handed over into it since, which this Stop's interrupt sends back and must hold. The interrupt and the "already finished" note are unchanged. A Stop at a starting agent still always writes. * test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop * chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled * fix(native-chat): any later Stop event ends a person's Stop pause A person's Stop paused the queue until their next accepted turn or Resume, and a later Stop of another reason (the host stopping the agent, an eviction, a close) was ignored. Now the pause is the latest Stop event's: a later Stop of any reason ends a person's pause, and only a person's Stop pauses. The fold keeps the latest Stop event whatever its reason. An eviction of a resting chat writes no Stop event (a Stop that stops nothing writes nothing), so it cannot release held cards; a test pins that no event means no lift. * fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed A Stop pressed before the agent's turn shows in the journal (before Claude's echo, or before Codex opens the turn) records no turn. A second press once the turn showed compared that missing turn with the live one, wrote a second Stop event, and held a card queued between the presses. A repeat is now judged by what was sent since the Stop in force: with nothing sent after it (a refused send aside), a Stop that named no turn, or named the live one, is repeated and writes nothing. Anything sent since and not refused, including a send whose fate is unknown, makes the new press write, since its interrupt may send that card back to waiting. Tests: the two-press case across the turn showing; a steer between the presses settled unknown; and a Stop naming a turn that ended while the next card is sent but shows no turn yet, which writes and holds that card. The fold test that claimed an eviction path is renamed. * fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes A Stop or Resume row's value is read from disk with no shape check, and the pause fold stored whatever it found. A stored `stopEvent: null` would then throw on every pause check for that chat: the queue's pick, its send, and every queue update to clients. No build writes such a row, so this is hardening. The fold now reads a Stop only when it is an object with a string reason and a finite time, and a Resume only when it is `true`. Anything else is ignored: it pauses nothing and ends nothing. The row is still not treated as malformed, which could cut the history short. * fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends Every stop that ends work now writes the Stop's event before it ends the child: a person's close of the chat, an eviction (worktree teardown, orchestration stop, tab cleanup) and the idle sweep's stop of a start that never landed. A stop that ends nothing writes nothing, and quit writes none: its resume marker records why. The turn-end write reads the latest Stop event where every turn row is built, so the adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a person's Stop or close named, ending with no verdict of its own after that Stop, ends as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that found the turn running. When the provider refuses the interrupt and the turn runs on, a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop again after a refusal is a new Stop. * refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event The cause of a stop was threaded in memory from each entry through the host's stop step, the adapter router and each adapter's close onto the `ended` it settled with, and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters settle a turn they cut as interrupted with no verdict, the host's fallback does the same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the journal's latest Stop event to say whether it was a person's. - `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`. - Claude reads an error result after a person's Stop as their cancellation from the journal's Stop event (through the event sink), not from a per-turn slot, and a refused interrupt is the host's refusal row, not `withdrawTurnStop`. - An owed wind-down keeps no cause: its retry's fallback reads the Stop event. - The mutation context's Stop passes no cause: its step already wrote the event, and the delivery loop's child-end reason is read back from it. - A Stop pressed before its turn showed applies to the turn that opens under it, unless a send a person made since was accepted. * test(native-chat): a turn a later send opened is no Stop's that named no turn * test(native-chat): the restart test's death proof carries its detail * refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and the Stop names that turn, so the turn running instead never reads as the person's by its id alone. * fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next A Stop that named no turn read as the person's cancellation for every later turn that opened after it, until a send a person made was accepted. The queue's drain, orchestration mail and a restart continuation send as the host, so a turn they opened long after, cut by a crash, read "Interrupted" as if the person had stopped it. The Stop now applies only to the first turn opened after it. * fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The translator judged whether a person's Stop explained it by its own copy of the Stop rule, which ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed". The translator now writes such an end as interrupted with no verdict and no error row whenever a person's Stop may name the turn, and the journal's one rule decides as it writes the end. * fix(native-chat): a person's Stop and /clear each name why they end the agent The host's mutation path ended the agent with one "recorded" ending for every caller, which read back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop `user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat. * fix(native-chat): a host stop judges whether it ends work after the provider's rows land A close, eviction or host stop decided whether it ended a running turn from the journal as it stood, while the provider's own rows (the turn its echo opened) could still be in the session's event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It now reads after the sink drains, as a person's Stop does, through the same check; a drain that fails or takes over a second reads working. * fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed, Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.". A Stop that ends the provider's session ends whatever is in flight, so its event now names the live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only the turn the Stop names. The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at its line budget. * fix(native-chat): the idle sweep reads working by the same rule as a stop's event The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event, which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed work now reads the main agent working the way every session list and the event writer do. * test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain A host stop now drains the session's sink once to judge whether it ends work, so the tests that fail the eviction's drain-published step skip that first drain. * fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop event, whatever send it retires: a person's Stop pause holds through it. * fix(native-chat): stopping a start that carries no send writes no Stop event A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried. A start with a send already reads working, so the clause only mattered for a start with none, which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle clock, which is why the idle sweep had been changed to close the conversation in the same pass. The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before. * test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns, restart as a process that dies with no close, which like a quit writes no Stop event, and assert that both the Stop's and the restart's pauses are in force first. * fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason An eviction or host stop that landed while a person's Stop or close was already ending the same turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of that turn read as news. A host reason now writes nothing while a person's Stop still decides what runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does, so the mail turn after it is not the turnless Stop's. * fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop The translator left an error result that names no reason to the journal's Stop rule whenever a person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn opened next. So a real error on a later turn read "Failed" with its error text dropped. The translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core `turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree. * fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it A person's Stop pressed while the agent starts names no turn, and the send it stopped is cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration mail, a restart continuation, the queue's drain, all of which send as the host), so a host eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A Stop that named no turn now binds only a turn no send journaled after it opened: any send since, of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex accepts it, instead of opening the stopped send's own turn first. * test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new sequence, so by sequence alone it would bind the next turn opened after the rewind. A send journaled after the restated row voids that binding (the previous commit), which this pins. * fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but the relaunch still added the error row saying the provider stopped mid-response, which a live Stop never writes. The settle now skips that row when every turn it interrupts is the person's Stop's by the journal's one rule; a crash nobody stopped keeps it. * test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn * fix(native-chat): a host stop whose sink drain fails reads the journal as it stands A host stop drains the session's sink before judging whether it ends work, and a failed or slow drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing, which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is now best effort: the stop goes ahead either way and only its record is at stake, so a failed or slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule. * fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened A person's Stop pressed before any turn showed names no turn. It bound the first turn opened after it, then (5ead1f6bccd) any turn opened by no later send, so a turn the host started for a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host eviction of it wrote no Stop event when its send had been abandoned by the close first. The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal aliases to the submission), and a handed-over send's item sits at its handover, so the target set is derived from the journal. A card the Stop held is handed over after it, so it is no target; a Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a restated Stop binds no turn opened after it. With no turn running, a host stop defers to the person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks before its echo row lands, passes the send its echo acknowledged. * fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal A drain past its bound may still hold the turn's row, while the echo's acceptance has already landed, so the journal as it stands read nothing running: a person's close of that turn wrote no Stop event and the turn read as news. The two drain outcomes now differ: one that failed has nothing more to deliver, so the journal's read holds (as before); one still running reads working. * test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's The branch had no test. An eviction with only the stopped send unanswered writes nothing; one with a send made after the Stop still unanswered writes its event. * fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due The previous commit read every drain past its bound as working, so a host eviction or stop of an agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened no turn the journal holds, the race it was for; otherwise the journal's read holds. * test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule that deferred when any one was would pass the old control. * fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered into a running turn, whose item carries that turn's scope, are skipped. * feat(native-chat): a chat reads Stopping from the person's Stop until the work it stopped ends The host derives it on each journal publish from the Stop's event, the live turn and the Stop's own answer, and publishes it as an optional field on the session status and the main agent's row. Clients present it: the chat's tail line and Stop control, the sidebar row, worktree ps and the phone's row. The chat and the phone also read their own Stop press until its request answers. * test(native-chat): pin Stopping on the phone and across mixed versions * test: give touched fake journals and mocks their SAFETY notes * test(native-chat): a turn waiting on the person reads attention, never Stopping * test(native-chat): the sidebar row follows Stopping when it is the only field that moved * test(mobile): the phone reads Stopping from its own Stop until the request answers * test(mobile): type the held Stop request instead of casting it * chore: keep the base lockfile (a local pnpm run rewrote it) * fix(native-chat): narrow the Stop note's optional failure; type the phone test's reply * test(native-chat): type the Stop test envelope's fields narrowly * fix(native-chat): a Stop the agent declined, or whose child end failed, says so while the turn runs on A Stop naming the turn that still runs, refused by the agent, now writes the Stop's refused fact instead of 'already finished'. A session-ending Stop whose child end fails while the work runs on revises its note to unconfirmed. * fix(native-chat): Stopping holds while any press of the Stop took A repeat press refused after an earlier press took no longer clears Stopping. Exit early when the Stop named a turn that is not the live one. * fix(native-chat): keep Stop enabled while the host says Stopping Only this client's own Stop request in flight disables Stop and Esc. A repeat Stop is how a stop the provider took but never answered escalates. * fix(sidebar): every agent row says Stopping in place of its tool line The dashboard row, which the sidebar's non-compact mode also draws, read the last tool line while a person's Stop ended the turn. It now shares the compact row's rule. * fix(mobile): the worktree list sees Stopping change on its own A snapshot whose only change was the host dropping Stopping compared equal and was thrown away, leaving the row on Stopping. * refactor(native-chat): fold the status feed in src/shared for both clients The snapshot merge and the contact-loss strip move out of the renderer feed so the phone folds the same stream the same way. * feat(mobile): let phones read the structured session status stream agentSession.subscribeStatus joins the mobile allowlist. The agent-session methods move to their own file, which the at-cap allowlist spreads in, and the allowlist test reads the Set instead of parsing the source. * feat(mobile): the phone chat reads Stopping from the host, like the desktop One status stream per client, opened on a host that advertises the status feed; a refusal to phones reads as no feed. The chat reads Stopping from the host or its own press, and holds Stop only while its own request is in flight. * test: give the new fakes checked types or a SAFETY reason * revert(native-chat): drop the refused-named-turn rewrite of a Stop's note Codex can send its refusal before the turn's end frames, so reading the turn as still live after a flush races; the Codex Stop that ends nothing is handled by ending the process instead. The base's 'already finished' note and its test expectation return. The Claude wind-down failure revise stays. * fix(mobile): release the status stream when the host ends it; refusals last one connection The feed now drops the handle of a stream the host ended or refused, so the logical client never replays it on a later session. A refusal to phones holds for one connection, so a host updated while the phone stays paired is asked again. * perf(native-chat): read the live turn's opener from its record when deriving Stopping After a Stop that named no turn, every later commit walked and copied the whole journal to find the live turn's record. The derivation now reads that record off the rendered snapshot's tail and decides with the same rule. * refactor(mobile): move the method-unavailable check into transport The status feed imported it from the Files tab's fallback. No behaviour change. * test(native-chat): a send after a Stop reads Working before its turn opens Pins the derivation's running-only read of the newest turn: the stopped turn, already ended, must not keep the next send on Stopping. * fix(sidebar): the compact row leads with Stopping so a narrow sidebar keeps it whole At the default width the row read 'Codex Chat - Stoppin…': the model and time keep their room and the line truncates from the end. Stopping now leads the line the way monitoring already does, so the chat name is what gets cut. Also pins that the turn bar keeps its running clock while the tail line says Stopping. * test(native-chat): the retry of a close whose exit was unproven writes no second Stop event The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its event stands alone and the child's end keeps the cause, for a person's close and an eviction. * fix(native-chat): read and write a Stop's answer by the turn its event records Stop notes are now one row per turn, keyed by the turn the Stop's event records. Performing a Stop and deriving Stopping share that key. A refused or unconfirmed answer never overwrites one that took at the same key; only a session-ending Stop's failed wind-down downgrades it, and that step now revises the note the Stop actually wrote, carried on the wind-down. Stopping reads the turn's note whenever it was first written, plus newer notes no other turn owns. Test fixtures gain the host logger and the phone's quietRepeatedStop. * refactor(native-chat): read a Stop's target once for its event and its note The chat's Stop now reads what it is aimed at (the named turn and whether the Stop ends the provider session) once, and both its event and its note's key derive their turn from that one value through the same rule. Adds the host test for a Stop naming an ended turn on a provider whose Stop ends the session. * fix(native-chat): the host never steers a message into a turn a Stop is ending A queued card's Send-now, or a send made while a person's Stop ends the turn, went to the agent as a steer into that turn. The delivery loop now holds any waiting message while the host's own Stopping reading holds, and sends it as its own turn once the turn ends. The Stopping reader also stops at the Stop's position and looks the turn's note up by key, instead of walking the whole journal. * feat(native-chat): while Stopping, the composer says a message runs after the stop Desktop and phone: the composer placeholder reads "Queue a message to run after the stop" while the chat reads Stopping, and a queued card's Steer (and the desktop's steer shortcut) is held. New key translated in all 6 catalogs. * refactor(native-chat): the chat pane's Stop controls live in their own module The pane went over its line limit once merged with main. Its Stopping reading, the press that holds Stop, and the steer and placeholder it hands the composer move to native-chat-structured-stop-controls.ts. * fix(native-chat): hold a send at its handover, reading the feed's own Stopping The hold was checked when the delivery step started, but the handover runs in a later step after waiting on the agent's start, so a Stop landing in between let a new send steer into the stopping turn. The check now runs at the handover. It reads the status feed's projection for the commit instead of rendering the journal again, so holding a send adds no journal read of its own. * refactor(native-chat): one display status decides Stopping on every surface agentStopDisplayStatus combines whether the agent works, the host's flag and this client's own press. The chat pane, sidebar and dashboard rows, and the phone's chat all read it, instead of each combining the flags. * fix(native-chat): Stopping holds until the stopped turn ends, whatever the Stop's answer A Stop the agent declined, or whose end went unconfirmed, used to drop the chat back to Working. It now stays on Stopping until the turn ends, and Stop stays enabled so a repeat press escalates. The Stop's answer is no longer read for Stopping, so its note goes back to the key the base gives it (the restore of queued-stop.ts and the removed key test landed in the previous commit). The note still keeps a press that took over a later refusal, and a failed process end still says the Stop went unconfirmed. * fix(native-chat): a Stop binds only the turn it actually stopped A Stop pressed before any turn showed used to claim, at end-write time, whatever turn the stopped send later opened, even when the Stop stopped nothing. A turn that then died on its own read as "Interrupted" (your cancellation) instead of "Failed". Now a person's Stop that named no turn binds, in memory only, every turn that ends while the Stop settles, and afterwards only the turn its interrupt took. The settle ends a still-running stopped turn once. A relaunch finds nothing in memory, so an unsettled turnless Stop binds no turn. A Codex Stop whose answered turn does not open within its wait, or whose send's answer was lost, now answers refused, so the host ends the child and the turn can never run. * fix(native-chat): keep the person's queue pause and close binding after a Stop settles A host stop or eviction with no turn running now defers to a person's Stop while its queue pause still holds with nothing sent since, read from rows, so a held card is not handed off on reopen after a Stop that did nothing or whose kill failed. A person's close that named no turn opens a settle around its child's end, so a turn that end cuts reads as theirs. A press opens its settle only when the latest Stop event is its own or the one in force it repeats: a late Stop, a card's interrupt or a lost event row reopens no earlier Stop. A Codex Stop that cannot reach a turn still able to open says the Stop is unconfirmed rather than that no turn ran, and a second Stop still reaches a turn an earlier wait left unopened. Also drops the unused openedBy plumbing and the unreachable "a written cancellation stays one" rule, and pins a relaunch after a named Stop. * fix(native-chat): keep a failed Stop's turn display-only, and settle edges off the commit path A Stop that failed marks the turn it could not stop for "Stopping…" only (JournalStopSettle.failedOn): no turn-end rule reads it, so that turn's own end with no verdict reads as a failure, not the person's. A settle edge writes no row, so it no longer goes through the journal's commit listener, which also delivers history, counts as activity for the idle sweep and schedules the queue drain. A narrow settle-edge hook republishes the status row and wakes the steer hold's handover, and nothing else. * fix(native-chat): a Codex Stop agrees on both presses when a turn is still owed, and pin the close's settle A Codex Stop that waited for a turn Codex answered a send into now answers "may still open" whenever that turn neither opened nor ended and its send is still owed, however the wait ended (it ran out, or the thread went idle). Before, a first press after an idle thread said no turn was running and kept Codex, while an identical second press ended it. Adds a test that a person's close the conversation outlives (as /clear does) closes its settle, so a later turn that ends on its own reads as a failure. * fix(native-chat): a Stop that failed before its turn showed still reads Stopping through that turn A Stop that failed with no turn open marked nothing, so the chat dropped to Working and the turn that then opened never read "Stopping…". The display-only mark now also covers that case: the first turn that opens after the Stop failed, provided no message was handed to the agent in between. No turn-end rule reads the mark, so that turn's own end with no verdict still reads as a failure. * test(native-chat): a Stop whose event row failed binds no turn to an earlier Stop With one ordered journal writer the Stop's event is in the fold when its write returns, so the press reads whether it owns the latest Stop from the fold instead of awaiting the write. Pins the case the read must refuse. * test(native-chat): name the settle, not a stream drain, in the Stop's own-end test * test(native-chat): a Codex Stop answered before Codex ends the turn reads interrupted throughout Codex answers an interrupt it took before it sends turn/completed (interrupted): on TurnAborted the app-server answers pending interrupts, then ends the turn, on one channel. The test fake did the reverse. It now answers first and ends the turn on a later read, and the tests that read the turn's end right after a Stop wait for it. New end-to-end test through the shipped host, journal and Codex adapter: with the real order, every end row of the stopped turn reads interrupted by the Stop (named, unnamed, and a Stop pressed while turn/start was in flight). Breaking the settle window turns the in-flight case red: the Stop's own end row then has no verdict, which reads as failed until Codex's end lands. * test(native-chat): Stopping ends with a Codex turn whose interrupt is answered before its end With Codex's real order (the interrupt's answer, then turn/completed interrupted), the status shows Stopping while the Stop settles, drops it once the turn ends, and never carries a verdict other than the person's cancellation. * fix(native-chat): a Codex Stop interrupts a turn Codex answered but has not opened at once A Stop that named no turn, made after Codex answered a send but before the turn opened, used to wait up to 5 s for the turn to open before interrupting, and ended the Codex process when it didn't. The stated reason, that Codex refuses an interrupt until it opens the turn, holds only part of the time: with no turn active, Codex takes an interrupt once its thread runs (turn_interrupt_inner), and refuses it with -32600 "no active turn to interrupt" before that or once the turn has ended. The Stop now sends the interrupt at once. Only on that refusal, while the turn has neither opened nor ended, does it wait for the turn to open (bounded at 5 s) and send it once more. A turn that ended meanwhile was nothing to stop. One that never opens, or that an earlier wait already gave up on, fails the Stop, and the host ends the child as before. Sends still wait for the turn to open before steering into it. The test fake models Codex taking an interrupt once the thread runs (run()). * fix(mobile): name how the phone's status stream is released in the subscription inventory Main made each inventory entry state its release; the status feed's stream is released from its subscribe params, as the session event stream is. * fix(native-chat): every Codex Stop waits for an answered turn to start, as the first did A Stop whose interrupt Codex refused as finding no active turn skipped the wait when an earlier wait, a Stop's or a send's, had already given up on that turn. Every press now waits its own bound and retries once if the turn starts, so a turn that opens during a later press is still stopped. Both presses still reach the same verdict when it never starts. * fix(codex): never steer a turn whose interrupt Codex answered Codex answers an interrupt as the turn aborts, before it sends that turn's turn/completed. In that gap the adapter still counted the turn as running, so a message handed over right after a Stop settled (the Stop's own end row already reads the turn ended) went out as turn/steer, which Codex refused with -32600 "no active turn to steer", and only then as turn/start. The adapter now marks a turn whose interrupt Codex answered as aborted until its turn/completed, never steers into it, and starts the message's own turn directly. Steering a turn that is genuinely running is unchanged. * fix(native-chat): a message sent while Stopping is queued as a card, whatever the setting While the chat reads Stopping (the host's flag or this client's own Stop in flight) there is no turn left to steer into, so the desktop asks the host to queue the send even with the queueing setting off, and it is never drawn as a bubble inside the turn being stopped. The phone already queued every send on a capable host; a test now pins that it does so while Stopping. * fix(native-chat): a message queued while Stopping is a card at once, not after the stop A person's Stop holds the session's lane until Codex answers its interrupt, and a send was admitted only behind it. By then the turn read ended, so a send that asked to be queued went out plain: no card for the whole of Stopping, then a bubble and a new turn. While the host reads that a person's Stop is ending the work, a text send that asks to be queued is admitted without waiting for the lane: the same ledger and lease admission, and a plan that only writes the card through the journal's ordered writer. The card runs when the stop lands; the Stop's pause holds only cards queued before it. Anything else, including a Stop that settled by the time the send runs, takes the lane as before. The Codex test fake now drops the active turn when it takes an interrupt, as Codex does before it answers, so a turn/start after the answer opens a new turn. * fix(native-chat): a Codex Stop that ends the child before any turn opened withdraws its send A Stop on a Codex turn that was answered but never opened ends the Codex process. That end settled the send as in doubt (unknown, recovered), and the client's outbox holds every later send behind a send in doubt until the person presses Retry, which re-sends the very message they stopped. The chat looked stuck. Codex records a prompt only once its turn has started, so a send whose turn never opened never ran. When the Stop's refusal says so (turnMayOpen), the child end now settles the unanswered sends as withdrawn, the verdict Codex's own interrupted-turn end already gives an unechoed send. The flag rides on the owed wind-down, so a retry after a failed child end withdraws them too. Claude's child end still leaves its unanswered send in doubt. * fix(native-chat): derive the withdrawal of a Codex send whose turn never opened Replaces the flag the Stop carried to the child's end, and its copy on the owed wind-down, with a reading of the journal at the settlement that lands. A Codex child's unanswered send is withdrawn when a person's Stop is in force since it was sent and no turn row ran, or was written, after it; any other end (a turn that opened, a host's close, a crash, Claude) still leaves it in doubt. A retried wind-down reads the same rows, so it withdraws the same sends. * fix(native-chat): a card queued while Stopping runs past the cards the Stop holds A card queued before a person's Stop waits under its pause until Resume. One queued after it, as a message sent while Stopping now is, was stuck behind them too, since the queue never reorders. Such a card was asked for after the Stop, so it runs when the stop lands, past the cards held only by that Stop's pause; a returned card and the restart and /clear pauses still hold everything behind them. Also: the host's own Stopping reading is gated on working, as the published flag is, so a failed Stop's mark never reads Stopping on an idle session; a send that falls back to the lane re-reads the conversation's journal there; and the end-to-end test asserts the queue's pause rather than a per-card field. * fix(native-chat): a paused queue labels only the cards it holds Since a card queued after a person's Stop runs past the cards the Stop holds, labelling every card "paused" while the queue's pause is published misreads that card. The host now marks each card its pause holds (heldByPause, a new optional field), and the desktop and phone label only those. An older host marks none, so a client keeps today's labels; an older client ignores the field. Adds a test of the desktop's own send through the real outbox: while the chat reads Stopping, the request asks the host to queue it and no bubble is drawn, on a host that advertises the queue. * revert(native-chat): defer the per-card queue pause label to the queue's rollout The heldByPause field and its labels are visible only where the host advertises the queued-messages capability, which shipped hosts do not yet do. Deferred to that rollout; the real-outbox send test stays. * fix(native-chat): while Stopping, say and show what a send does where the queue is dark Shipped hosts do not advertise the queued-messages capability, so a message sent while Stopping goes out plain: the host holds it until the stopped turn ends and then runs it as its own turn. The composer still said "Queue a message to run after the stop", and the message was drawn inside the turn being stopped. Now the placeholder reads "Send a message to run after the stop" where the host does not queue sends, and "Queue a message…" only where it does (desktop and phone, all six catalogs). A send this client made that the host has not recorded yet is drawn after the Stopping line while the chat reads Stopping, as a message held behind a running command already is; once the host hands it over it opens its own turn. Client presentation only. * fix(native-chat): keep a send in doubt when a turn was open for it The derived withdrawal read a turn as open for a send only if it still ran or was written after the send. A send steered into a running Codex turn whose interrupt failed met neither once the adapter's end settled that turn ahead of the host's settle, so it read withdrawn, though Codex drains a steer into the running turn and may hold it. A turn that ended after the send was handed over was open for it too: such a send stays in doubt, as before. Pins that case, and that a send made after the Stop, to a child that then dies before its turn opens, stays in doubt. * fix(native-chat): restore the per-card queue pause label Kept after all: a paused queue labels only the cards it holds (heldByPause), which is visible only where the host advertises the queued-messages capability. * fix(native-chat): draw only a send made while Stopping after the Stopping line Every send the host had not recorded yet was drawn after the Stopping line, including one made just before the Stop, which the host steers into the turn; it then jumped up into that turn once recorded. The outbox now marks a send made while the chat reads Stopping, and only those wait after the line. * fix(native-chat): withdraw a Codex send by whether it started its own turn, not by timing Whether a turn was open for a send was read from end times: a turn that ended after the send's handover counted. A send made while a Stop ended the turn is handed over once that turn reads ended, yet Codex's own end for it can arrive later, so such a send whose own turn never opened read in doubt again, and the chat's queue held behind it. The handover already records where the send went: its message joins the turn running then (a steer) or belongs to no turn (it starts its own). Only a send that started its own turn, with none opened since, is withdrawn; one that joined a running turn, or has no recorded place, stays in doubt. The Codex test fake takes an answered interrupt as Codex does, dropping the turn before its end arrives. * refactor(native-chat): move queue-while-stopping to its own follow-up The queued-messages capability is off on every shipped host (#21062), so the parts of this PR that act only when it is on move to a follow-up stacked on this one: admitting a queued card while a Stop holds the session's lane, a card queued after a Stop running past the cards it holds, the per-card pause mark, and asking the host to queue a send made while Stopping. This PR keeps the Stopping state, the host's steer hold, the rule that never steers a turn whose interrupt Codex answered, and what a send while Stopping looks like where the queue is off. * fix(native-chat): leave no Stop row when the Stop took back a send that never ran A Stop on a Codex send whose turn never opened ends the child, and the child's end takes the send back into the composer. The Stop still wrote "Cancellation requested." at the conversation level, so with the send gone it sat under the previous finished turn and read as if that turn had been stopped. A Stop that found no turn running and whose child end took back every send it found now writes no row; a Stop of a running turn, or one that leaves a send in doubt, still does. * fix(native-chat): count a send whose answer was lost when a Stop takes it back The no-row rule counted only pending sends, but the child's end also takes back a send this process left in doubt when Codex's turn/start answer was lost. That case still wrote "Cancellation requested." under the previous turn. Both now read one predicate, so they cannot drift apart. * test(native-chat): pin which sends a Stop's child end can take back A send an earlier process left in doubt is never withdrawn and never holds the row back, and a queued card's send is never counted. * fix(native-chat): read the host's Stopping beside main's startup phase Main now reads only the startup phase from the status feed and no longer publishes which child is starting. The chat reads the host's Stopping from its own hook beside it, and the Stopping bridge test mocks the execution-host lookup main's owner resolution now calls. * test(native-chat): read the outbox reconcile from where main moved it * fix(native-chat): a retried message no longer waits behind a later Stop Retry dropped the Stop it had outlived but kept the mark that it was sent while a Stop was ending a turn, so a retried message waited behind whatever later, unrelated turn a Stop was ending. Retry is a new send: drop that mark too. * fix(native-chat): word a send after a Stop by whether this send will queue The 'queue a message to run after the stop' placeholder read the host's queue capability alone. A send queues only when the host queues and this send asks it to: the queue setting is on and no pending prompt blocks the queue. Desktop and phone now word the placeholder from that same decision their send uses. * test(native-chat): one test per case for the words of a send after a Stop * refactor(native-chat): the dictation hook owns the composer's dictation state Keeps NativeChatComposer within its line limit after the Stop props and main's /context answer both landed in it. * refactor(native-chat): name the dictation hook for what it owns now * fix(native-chat): a Stop's note says it took once a joined close proves the exit A session-ending Stop whose child's end failed revises its note to 'unconfirmed'. Since main's #24862, the next Stop joins that close rather than stopping again, and wrote no note, so a close that then proved the exit left 'unconfirmed' under a turn that ended. The close now carries the note it settles, and its proven exit revises it to 'Cancellation requested.'. A join that fails again leaves it unconfirmed. * fix(native-chat): a proven turn end says a Stop's unconfirmed note took Replaces the note carried on the child's close. Every settlement that ends turns interrupted on a proven exit, live or after a crash, also revises an unconfirmed Stop note on those turns to 'Cancellation requested.', found by the note's turn scope, in the same batch. A note a Stop wrote before its turn showed is re-keyed onto the running turn when it becomes unconfirmed, in one batch, so that end finds it. Known limit: with no turn open yet, the note keeps its key and no turn's end revises it. * test(native-chat): a Stop's unconfirmed note says it took when the agent exits on its own * test(native-chat): name the Codex handle as main's opaque handle does * test: restore the provider handle import the main merge dropped * fix: derive Stop note wording from interrupted turns * test: name the raw replay case for what it covers * refactor(native-chat): move the waiting-slot split into its own hook * test: follow main's chat font-size rename in the stopping tests * test: follow main's single live-line value in the Stopping tests * test: give the android live-line fixtures the stopping field * chore: keep the session host under its line limit after the main merge * chore: keep the composer test and the phone chat view under their line limits after the main merge The Stop control now disables itself while Stopping, so the composer passes the flag through and its test file stays as main has it. The phone chat header's Stop moves to its own component. * perf(native-chat): read a Stop note's fields before parsing its key on every snapshot Every snapshot projects each item through the Stop-note read, and each new snapshot rebuilds the index of Stop notes by turn. Both parsed every item's key first; they now check the row's kind and turn scope (and, for the projection, its unconfirmed-stop failure) before the parse. Every Stop note is a status row, so what each finds is unchanged.
…ollow-up to the Stop-event plan, fixes STA-9348) (#25217) * test(native-chat): a Stop over a card sent now into the running turn keeps it paused Red on main: Codex's turn end withdraws the steered hand-off and the queue sends the card again as a new host turn, with no pause recorded. * fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered A card sent now into the running turn was still pending when Stop judged the pause, so nothing was recorded; the interrupt then withdrew the hand-off, the card went back to waiting unpaused, and the queue sent it again as a new turn. The pause now counts a hand-off that may still return to waiting, judged with the appended row applied, so a withdrawal lands under the pause and an acceptance retires it in that same write. Codex and Claude both hit it. * test(native-chat): the Claude re-send case fails on its diff, inside the test's budget * test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it The provider's answer, the provider dying, the chat closing, a restart, a withdrawal still owed at open, and a /clear (refused until the hand-off ends, then carrying every waiting card paused 'cleared'); each ends with the queue sending again. * fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card * refactor(native-chat): derive the queue's pause from Stop and Resume journal rows Stop now appends one journal row where it takes effect, before the interrupt, whatever the queue holds; Resume appends its own. The pause is a pure function of the fold: the latest Stop with no later Resume and no later accepted turn a person asked for. A /clear's carried cards name their source, which is the replacement's 'cleared' pause. Host-origin turns never lift either. One predicate decides which cards a pause holds; by default every waiting card without a hold of its own, including one queued after the Stop. The drain's consume re-judges it inside its own transaction. The rows are tombstones of an id no item takes, carrying the mark: a released host reads an unknown row kind as corruption and truncates the journal there. Deletes the stored pause (recordPause, the retire hook on every appended row, the settle-before-record step, mayReturnToWaiting and its row overlay) and the tests that only proved it retires. The queued_message_pauses table stays in the schema, unread and unwritten, for downgrade safety. * fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones A Stop's pause now holds only the cards queued before its row, plus a steer it withdrew, which returns to its own place. Each card records the journal position it was queued at, and the one hold rule compares that with the Stop row. A card queued after the Stop is a new instruction: it sends as usual, but the drain still stops at the first held card, so it never overtakes them. /clear's pause holds the cards it carried. Holding every card again is a one-line switch in that rule. * fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction The drain's pick and its consume now read one function, nextSendableQueuedCard, so a Stop row that lands between them holds a newer card behind an older held one exactly as the pick would. Notes why Stop and Resume ride a tombstone row. * fix(native-chat): stop creating the unused queue pause table The queue's pause is derived from journal rows, so nothing reads or writes queued_message_pauses. It was still created on every open "for downgrade safety", but an older build creates it itself when it opens the database, so the table only sat empty in every new database. The tests now pin that no pause table exists. * fix(native-chat): a Stop's pause never hides the restart pause A Stop holds only the cards queued before it. The pause derivation still returned the Stop alone whenever it was in force, so the restart pause was never considered: a card queued after the Stop, written by a host process that has since exited, sent by itself after Orca restarted, with no pause header and no Resume. A /clear pause that held nothing could hide it the same way. Every pause in force is now derived. A card is held if any of them holds it, and it names the first that does. The drain's pick, the consume transaction's re-check and the published header all read that one rule; the header names the pause holding the first card Resume would send. * test(native-chat): pin the Stop's no-resend, lift and held-card rules - The Claude and Codex Stop-withdraws-a-steer tests checked "not sent again" at one instant, before a queue ignoring the pause re-sends. They now wait for the stopped turn to end and re-check after a quiet window. - The deleted-card test read a card queued after the Stop, which sends whether or not a person's turn lifts it; it now reads the Stop's pause before and after that turn. - Unit cases pin that a Stop holds a card with no recorded position and one queued before a rewind. * refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller The Stop row that paused the queue becomes the general Stop event { reason, turnId?, at, caller? }, whose reason is the host's existing stop cause. It still rides a tombstone of a host-only id (a released host deletes the journal from the first unknown row kind), and Resume keeps its own marker on its own id. Only a person's Stop (reason user-stop) pauses the queue. * test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event * test(native-chat): pin that Stop and Resume rows never reach apps or count as history * test(native-chat): only a person's Stop event pauses the queue * test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop Through the real host: the event names the turn and who asked and is in the journal when the interrupt reaches the agent; at an agent still starting it is there before the start is ended and holds a card queued before it; an idle Stop writes one only when it withdrew a send; and the queue's claim re-judges a pause that landed after its pick. * test(native-chat): a card held at a starting agent is checked before the Stop's timing Also says precisely what the claim's in-transaction pause check defends against: the Stop and the drain share one serialized lane. * test(native-chat): a released build keeps and folds a journal holding Stop events Replays this build's rows from the released build's own journal database: every row is kept, the history after the Stop still folds, and an older client is sent only removed ids no item uses. * style(native-chat): format the Stop event changes * test(native-chat): type the released build's exports through one checked helper * fix(native-chat): the Stop/Resume row guard narrows to those tombstones only * test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade The Stop-event downgrade test ran in no CI lane: unit shards exclude the cross-version folder, and the cross-version lane runs a fixed file list that did not name it. It is now on that list. Its only case replayed the rows into a release's own fresh database, because that release cannot open the current host database. A second case opens the journal this build wrote with a main build that shares the database: it opens writable, keeps every row, appends, and this build then reopens it with the person's Stop still pausing the queue. * fix(native-chat): a Stop that stops nothing new writes no Stop event A Stop reaching a running agent wrote a Stop event on every press. Two presses before the first interrupt landed wrote two events, so a card queued between them counted as before the latest Stop and was held, though a card queued after a Stop should send normally. A Stop naming a turn that had already ended, as a phone sends late, also wrote an event for a turn it never stopped. It now writes one only when it withdrew a queued send, or stops something no event records yet: not a turn the journal no longer runs, and not the live turn a Stop still in force already names, unless a card was handed over into it since, which this Stop's interrupt sends back and must hold. The interrupt and the "already finished" note are unchanged. A Stop at a starting agent still always writes. * test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop * chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled * fix(native-chat): any later Stop event ends a person's Stop pause A person's Stop paused the queue until their next accepted turn or Resume, and a later Stop of another reason (the host stopping the agent, an eviction, a close) was ignored. Now the pause is the latest Stop event's: a later Stop of any reason ends a person's pause, and only a person's Stop pauses. The fold keeps the latest Stop event whatever its reason. An eviction of a resting chat writes no Stop event (a Stop that stops nothing writes nothing), so it cannot release held cards; a test pins that no event means no lift. * fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed A Stop pressed before the agent's turn shows in the journal (before Claude's echo, or before Codex opens the turn) records no turn. A second press once the turn showed compared that missing turn with the live one, wrote a second Stop event, and held a card queued between the presses. A repeat is now judged by what was sent since the Stop in force: with nothing sent after it (a refused send aside), a Stop that named no turn, or named the live one, is repeated and writes nothing. Anything sent since and not refused, including a send whose fate is unknown, makes the new press write, since its interrupt may send that card back to waiting. Tests: the two-press case across the turn showing; a steer between the presses settled unknown; and a Stop naming a turn that ended while the next card is sent but shows no turn yet, which writes and holds that card. The fold test that claimed an eviction path is renamed. * fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes A Stop or Resume row's value is read from disk with no shape check, and the pause fold stored whatever it found. A stored `stopEvent: null` would then throw on every pause check for that chat: the queue's pick, its send, and every queue update to clients. No build writes such a row, so this is hardening. The fold now reads a Stop only when it is an object with a string reason and a finite time, and a Resume only when it is `true`. Anything else is ignored: it pauses nothing and ends nothing. The row is still not treated as malformed, which could cut the history short. * fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends Every stop that ends work now writes the Stop's event before it ends the child: a person's close of the chat, an eviction (worktree teardown, orchestration stop, tab cleanup) and the idle sweep's stop of a start that never landed. A stop that ends nothing writes nothing, and quit writes none: its resume marker records why. The turn-end write reads the latest Stop event where every turn row is built, so the adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a person's Stop or close named, ending with no verdict of its own after that Stop, ends as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that found the turn running. When the provider refuses the interrupt and the turn runs on, a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop again after a refusal is a new Stop. * refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event The cause of a stop was threaded in memory from each entry through the host's stop step, the adapter router and each adapter's close onto the `ended` it settled with, and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters settle a turn they cut as interrupted with no verdict, the host's fallback does the same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the journal's latest Stop event to say whether it was a person's. - `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`. - Claude reads an error result after a person's Stop as their cancellation from the journal's Stop event (through the event sink), not from a per-turn slot, and a refused interrupt is the host's refusal row, not `withdrawTurnStop`. - An owed wind-down keeps no cause: its retry's fallback reads the Stop event. - The mutation context's Stop passes no cause: its step already wrote the event, and the delivery loop's child-end reason is read back from it. - A Stop pressed before its turn showed applies to the turn that opens under it, unless a send a person made since was accepted. * test(native-chat): a turn a later send opened is no Stop's that named no turn * test(native-chat): the restart test's death proof carries its detail * refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and the Stop names that turn, so the turn running instead never reads as the person's by its id alone. * fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next A Stop that named no turn read as the person's cancellation for every later turn that opened after it, until a send a person made was accepted. The queue's drain, orchestration mail and a restart continuation send as the host, so a turn they opened long after, cut by a crash, read "Interrupted" as if the person had stopped it. The Stop now applies only to the first turn opened after it. * fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The translator judged whether a person's Stop explained it by its own copy of the Stop rule, which ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed". The translator now writes such an end as interrupted with no verdict and no error row whenever a person's Stop may name the turn, and the journal's one rule decides as it writes the end. * fix(native-chat): a person's Stop and /clear each name why they end the agent The host's mutation path ended the agent with one "recorded" ending for every caller, which read back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop `user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat. * fix(native-chat): a host stop judges whether it ends work after the provider's rows land A close, eviction or host stop decided whether it ended a running turn from the journal as it stood, while the provider's own rows (the turn its echo opened) could still be in the session's event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It now reads after the sink drains, as a person's Stop does, through the same check; a drain that fails or takes over a second reads working. * fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed, Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.". A Stop that ends the provider's session ends whatever is in flight, so its event now names the live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only the turn the Stop names. The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at its line budget. * fix(native-chat): the idle sweep reads working by the same rule as a stop's event The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event, which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed work now reads the main agent working the way every session list and the event writer do. * test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain A host stop now drains the session's sink once to judge whether it ends work, so the tests that fail the eviction's drain-published step skip that first drain. * fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop event, whatever send it retires: a person's Stop pause holds through it. * fix(native-chat): stopping a start that carries no send writes no Stop event A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried. A start with a send already reads working, so the clause only mattered for a start with none, which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle clock, which is why the idle sweep had been changed to close the conversation in the same pass. The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before. * test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns, restart as a process that dies with no close, which like a quit writes no Stop event, and assert that both the Stop's and the restart's pauses are in force first. * fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason An eviction or host stop that landed while a person's Stop or close was already ending the same turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of that turn read as news. A host reason now writes nothing while a person's Stop still decides what runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does, so the mail turn after it is not the turnless Stop's. * fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop The translator left an error result that names no reason to the journal's Stop rule whenever a person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn opened next. So a real error on a later turn read "Failed" with its error text dropped. The translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core `turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree. * fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it A person's Stop pressed while the agent starts names no turn, and the send it stopped is cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration mail, a restart continuation, the queue's drain, all of which send as the host), so a host eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A Stop that named no turn now binds only a turn no send journaled after it opened: any send since, of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex accepts it, instead of opening the stopped send's own turn first. * test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new sequence, so by sequence alone it would bind the next turn opened after the rewind. A send journaled after the restated row voids that binding (the previous commit), which this pins. * fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but the relaunch still added the error row saying the provider stopped mid-response, which a live Stop never writes. The settle now skips that row when every turn it interrupts is the person's Stop's by the journal's one rule; a crash nobody stopped keeps it. * test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn * fix(native-chat): a host stop whose sink drain fails reads the journal as it stands A host stop drains the session's sink before judging whether it ends work, and a failed or slow drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing, which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is now best effort: the stop goes ahead either way and only its record is at stake, so a failed or slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule. * fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened A person's Stop pressed before any turn showed names no turn. It bound the first turn opened after it, then (5ead1f6bccd) any turn opened by no later send, so a turn the host started for a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host eviction of it wrote no Stop event when its send had been abandoned by the close first. The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal aliases to the submission), and a handed-over send's item sits at its handover, so the target set is derived from the journal. A card the Stop held is handed over after it, so it is no target; a Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a restated Stop binds no turn opened after it. With no turn running, a host stop defers to the person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks before its echo row lands, passes the send its echo acknowledged. * fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal A drain past its bound may still hold the turn's row, while the echo's acceptance has already landed, so the journal as it stands read nothing running: a person's close of that turn wrote no Stop event and the turn read as news. The two drain outcomes now differ: one that failed has nothing more to deliver, so the journal's read holds (as before); one still running reads working. * test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's The branch had no test. An eviction with only the stopped send unanswered writes nothing; one with a send made after the Stop still unanswered writes its event. * fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due The previous commit read every drain past its bound as working, so a host eviction or stop of an agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened no turn the journal holds, the race it was for; otherwise the journal's read holds. * test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule that deferred when any one was would pass the old control. * fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered into a running turn, whose item carries that turn's scope, are skipped. * feat(native-chat): a chat reads Stopping from the person's Stop until the work it stopped ends The host derives it on each journal publish from the Stop's event, the live turn and the Stop's own answer, and publishes it as an optional field on the session status and the main agent's row. Clients present it: the chat's tail line and Stop control, the sidebar row, worktree ps and the phone's row. The chat and the phone also read their own Stop press until its request answers. * test(native-chat): pin Stopping on the phone and across mixed versions * test: give touched fake journals and mocks their SAFETY notes * test(native-chat): a turn waiting on the person reads attention, never Stopping * test(native-chat): the sidebar row follows Stopping when it is the only field that moved * test(mobile): the phone reads Stopping from its own Stop until the request answers * test(mobile): type the held Stop request instead of casting it * chore: keep the base lockfile (a local pnpm run rewrote it) * fix(native-chat): narrow the Stop note's optional failure; type the phone test's reply * test(native-chat): type the Stop test envelope's fields narrowly * fix(native-chat): a Stop the agent declined, or whose child end failed, says so while the turn runs on A Stop naming the turn that still runs, refused by the agent, now writes the Stop's refused fact instead of 'already finished'. A session-ending Stop whose child end fails while the work runs on revises its note to unconfirmed. * fix(native-chat): Stopping holds while any press of the Stop took A repeat press refused after an earlier press took no longer clears Stopping. Exit early when the Stop named a turn that is not the live one. * fix(native-chat): keep Stop enabled while the host says Stopping Only this client's own Stop request in flight disables Stop and Esc. A repeat Stop is how a stop the provider took but never answered escalates. * fix(sidebar): every agent row says Stopping in place of its tool line The dashboard row, which the sidebar's non-compact mode also draws, read the last tool line while a person's Stop ended the turn. It now shares the compact row's rule. * fix(mobile): the worktree list sees Stopping change on its own A snapshot whose only change was the host dropping Stopping compared equal and was thrown away, leaving the row on Stopping. * refactor(native-chat): fold the status feed in src/shared for both clients The snapshot merge and the contact-loss strip move out of the renderer feed so the phone folds the same stream the same way. * feat(mobile): let phones read the structured session status stream agentSession.subscribeStatus joins the mobile allowlist. The agent-session methods move to their own file, which the at-cap allowlist spreads in, and the allowlist test reads the Set instead of parsing the source. * feat(mobile): the phone chat reads Stopping from the host, like the desktop One status stream per client, opened on a host that advertises the status feed; a refusal to phones reads as no feed. The chat reads Stopping from the host or its own press, and holds Stop only while its own request is in flight. * test: give the new fakes checked types or a SAFETY reason * revert(native-chat): drop the refused-named-turn rewrite of a Stop's note Codex can send its refusal before the turn's end frames, so reading the turn as still live after a flush races; the Codex Stop that ends nothing is handled by ending the process instead. The base's 'already finished' note and its test expectation return. The Claude wind-down failure revise stays. * fix(mobile): release the status stream when the host ends it; refusals last one connection The feed now drops the handle of a stream the host ended or refused, so the logical client never replays it on a later session. A refusal to phones holds for one connection, so a host updated while the phone stays paired is asked again. * perf(native-chat): read the live turn's opener from its record when deriving Stopping After a Stop that named no turn, every later commit walked and copied the whole journal to find the live turn's record. The derivation now reads that record off the rendered snapshot's tail and decides with the same rule. * refactor(mobile): move the method-unavailable check into transport The status feed imported it from the Files tab's fallback. No behaviour change. * test(native-chat): a send after a Stop reads Working before its turn opens Pins the derivation's running-only read of the newest turn: the stopped turn, already ended, must not keep the next send on Stopping. * fix(sidebar): the compact row leads with Stopping so a narrow sidebar keeps it whole At the default width the row read 'Codex Chat - Stoppin…': the model and time keep their room and the line truncates from the end. Stopping now leads the line the way monitoring already does, so the chat name is what gets cut. Also pins that the turn bar keeps its running clock while the tail line says Stopping. * test(native-chat): the retry of a close whose exit was unproven writes no second Stop event The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its event stands alone and the child's end keeps the cause, for a person's close and an eviction. * fix(native-chat): read and write a Stop's answer by the turn its event records Stop notes are now one row per turn, keyed by the turn the Stop's event records. Performing a Stop and deriving Stopping share that key. A refused or unconfirmed answer never overwrites one that took at the same key; only a session-ending Stop's failed wind-down downgrades it, and that step now revises the note the Stop actually wrote, carried on the wind-down. Stopping reads the turn's note whenever it was first written, plus newer notes no other turn owns. Test fixtures gain the host logger and the phone's quietRepeatedStop. * refactor(native-chat): read a Stop's target once for its event and its note The chat's Stop now reads what it is aimed at (the named turn and whether the Stop ends the provider session) once, and both its event and its note's key derive their turn from that one value through the same rule. Adds the host test for a Stop naming an ended turn on a provider whose Stop ends the session. * fix(native-chat): the host never steers a message into a turn a Stop is ending A queued card's Send-now, or a send made while a person's Stop ends the turn, went to the agent as a steer into that turn. The delivery loop now holds any waiting message while the host's own Stopping reading holds, and sends it as its own turn once the turn ends. The Stopping reader also stops at the Stop's position and looks the turn's note up by key, instead of walking the whole journal. * feat(native-chat): while Stopping, the composer says a message runs after the stop Desktop and phone: the composer placeholder reads "Queue a message to run after the stop" while the chat reads Stopping, and a queued card's Steer (and the desktop's steer shortcut) is held. New key translated in all 6 catalogs. * refactor(native-chat): the chat pane's Stop controls live in their own module The pane went over its line limit once merged with main. Its Stopping reading, the press that holds Stop, and the steer and placeholder it hands the composer move to native-chat-structured-stop-controls.ts. * fix(native-chat): hold a send at its handover, reading the feed's own Stopping The hold was checked when the delivery step started, but the handover runs in a later step after waiting on the agent's start, so a Stop landing in between let a new send steer into the stopping turn. The check now runs at the handover. It reads the status feed's projection for the commit instead of rendering the journal again, so holding a send adds no journal read of its own. * refactor(native-chat): one display status decides Stopping on every surface agentStopDisplayStatus combines whether the agent works, the host's flag and this client's own press. The chat pane, sidebar and dashboard rows, and the phone's chat all read it, instead of each combining the flags. * fix(native-chat): Stopping holds until the stopped turn ends, whatever the Stop's answer A Stop the agent declined, or whose end went unconfirmed, used to drop the chat back to Working. It now stays on Stopping until the turn ends, and Stop stays enabled so a repeat press escalates. The Stop's answer is no longer read for Stopping, so its note goes back to the key the base gives it (the restore of queued-stop.ts and the removed key test landed in the previous commit). The note still keeps a press that took over a later refusal, and a failed process end still says the Stop went unconfirmed. * fix(native-chat): a Stop binds only the turn it actually stopped A Stop pressed before any turn showed used to claim, at end-write time, whatever turn the stopped send later opened, even when the Stop stopped nothing. A turn that then died on its own read as "Interrupted" (your cancellation) instead of "Failed". Now a person's Stop that named no turn binds, in memory only, every turn that ends while the Stop settles, and afterwards only the turn its interrupt took. The settle ends a still-running stopped turn once. A relaunch finds nothing in memory, so an unsettled turnless Stop binds no turn. A Codex Stop whose answered turn does not open within its wait, or whose send's answer was lost, now answers refused, so the host ends the child and the turn can never run. * fix(native-chat): keep the person's queue pause and close binding after a Stop settles A host stop or eviction with no turn running now defers to a person's Stop while its queue pause still holds with nothing sent since, read from rows, so a held card is not handed off on reopen after a Stop that did nothing or whose kill failed. A person's close that named no turn opens a settle around its child's end, so a turn that end cuts reads as theirs. A press opens its settle only when the latest Stop event is its own or the one in force it repeats: a late Stop, a card's interrupt or a lost event row reopens no earlier Stop. A Codex Stop that cannot reach a turn still able to open says the Stop is unconfirmed rather than that no turn ran, and a second Stop still reaches a turn an earlier wait left unopened. Also drops the unused openedBy plumbing and the unreachable "a written cancellation stays one" rule, and pins a relaunch after a named Stop. * fix(native-chat): keep a failed Stop's turn display-only, and settle edges off the commit path A Stop that failed marks the turn it could not stop for "Stopping…" only (JournalStopSettle.failedOn): no turn-end rule reads it, so that turn's own end with no verdict reads as a failure, not the person's. A settle edge writes no row, so it no longer goes through the journal's commit listener, which also delivers history, counts as activity for the idle sweep and schedules the queue drain. A narrow settle-edge hook republishes the status row and wakes the steer hold's handover, and nothing else. * fix(native-chat): a Codex Stop agrees on both presses when a turn is still owed, and pin the close's settle A Codex Stop that waited for a turn Codex answered a send into now answers "may still open" whenever that turn neither opened nor ended and its send is still owed, however the wait ended (it ran out, or the thread went idle). Before, a first press after an idle thread said no turn was running and kept Codex, while an identical second press ended it. Adds a test that a person's close the conversation outlives (as /clear does) closes its settle, so a later turn that ends on its own reads as a failure. * fix(native-chat): a Stop that failed before its turn showed still reads Stopping through that turn A Stop that failed with no turn open marked nothing, so the chat dropped to Working and the turn that then opened never read "Stopping…". The display-only mark now also covers that case: the first turn that opens after the Stop failed, provided no message was handed to the agent in between. No turn-end rule reads the mark, so that turn's own end with no verdict still reads as a failure. * test(native-chat): a Stop whose event row failed binds no turn to an earlier Stop With one ordered journal writer the Stop's event is in the fold when its write returns, so the press reads whether it owns the latest Stop from the fold instead of awaiting the write. Pins the case the read must refuse. * test(native-chat): name the settle, not a stream drain, in the Stop's own-end test * test(native-chat): a Codex Stop answered before Codex ends the turn reads interrupted throughout Codex answers an interrupt it took before it sends turn/completed (interrupted): on TurnAborted the app-server answers pending interrupts, then ends the turn, on one channel. The test fake did the reverse. It now answers first and ends the turn on a later read, and the tests that read the turn's end right after a Stop wait for it. New end-to-end test through the shipped host, journal and Codex adapter: with the real order, every end row of the stopped turn reads interrupted by the Stop (named, unnamed, and a Stop pressed while turn/start was in flight). Breaking the settle window turns the in-flight case red: the Stop's own end row then has no verdict, which reads as failed until Codex's end lands. * test(native-chat): Stopping ends with a Codex turn whose interrupt is answered before its end With Codex's real order (the interrupt's answer, then turn/completed interrupted), the status shows Stopping while the Stop settles, drops it once the turn ends, and never carries a verdict other than the person's cancellation. * fix(native-chat): a Codex Stop interrupts a turn Codex answered but has not opened at once A Stop that named no turn, made after Codex answered a send but before the turn opened, used to wait up to 5 s for the turn to open before interrupting, and ended the Codex process when it didn't. The stated reason, that Codex refuses an interrupt until it opens the turn, holds only part of the time: with no turn active, Codex takes an interrupt once its thread runs (turn_interrupt_inner), and refuses it with -32600 "no active turn to interrupt" before that or once the turn has ended. The Stop now sends the interrupt at once. Only on that refusal, while the turn has neither opened nor ended, does it wait for the turn to open (bounded at 5 s) and send it once more. A turn that ended meanwhile was nothing to stop. One that never opens, or that an earlier wait already gave up on, fails the Stop, and the host ends the child as before. Sends still wait for the turn to open before steering into it. The test fake models Codex taking an interrupt once the thread runs (run()). * fix(mobile): name how the phone's status stream is released in the subscription inventory Main made each inventory entry state its release; the status feed's stream is released from its subscribe params, as the session event stream is. * fix(native-chat): every Codex Stop waits for an answered turn to start, as the first did A Stop whose interrupt Codex refused as finding no active turn skipped the wait when an earlier wait, a Stop's or a send's, had already given up on that turn. Every press now waits its own bound and retries once if the turn starts, so a turn that opens during a later press is still stopped. Both presses still reach the same verdict when it never starts. * fix(codex): never steer a turn whose interrupt Codex answered Codex answers an interrupt as the turn aborts, before it sends that turn's turn/completed. In that gap the adapter still counted the turn as running, so a message handed over right after a Stop settled (the Stop's own end row already reads the turn ended) went out as turn/steer, which Codex refused with -32600 "no active turn to steer", and only then as turn/start. The adapter now marks a turn whose interrupt Codex answered as aborted until its turn/completed, never steers into it, and starts the message's own turn directly. Steering a turn that is genuinely running is unchanged. * fix(native-chat): a message sent while Stopping is queued as a card, whatever the setting While the chat reads Stopping (the host's flag or this client's own Stop in flight) there is no turn left to steer into, so the desktop asks the host to queue the send even with the queueing setting off, and it is never drawn as a bubble inside the turn being stopped. The phone already queued every send on a capable host; a test now pins that it does so while Stopping. * fix(native-chat): a message queued while Stopping is a card at once, not after the stop A person's Stop holds the session's lane until Codex answers its interrupt, and a send was admitted only behind it. By then the turn read ended, so a send that asked to be queued went out plain: no card for the whole of Stopping, then a bubble and a new turn. While the host reads that a person's Stop is ending the work, a text send that asks to be queued is admitted without waiting for the lane: the same ledger and lease admission, and a plan that only writes the card through the journal's ordered writer. The card runs when the stop lands; the Stop's pause holds only cards queued before it. Anything else, including a Stop that settled by the time the send runs, takes the lane as before. The Codex test fake now drops the active turn when it takes an interrupt, as Codex does before it answers, so a turn/start after the answer opens a new turn. * fix(native-chat): a Codex Stop that ends the child before any turn opened withdraws its send A Stop on a Codex turn that was answered but never opened ends the Codex process. That end settled the send as in doubt (unknown, recovered), and the client's outbox holds every later send behind a send in doubt until the person presses Retry, which re-sends the very message they stopped. The chat looked stuck. Codex records a prompt only once its turn has started, so a send whose turn never opened never ran. When the Stop's refusal says so (turnMayOpen), the child end now settles the unanswered sends as withdrawn, the verdict Codex's own interrupted-turn end already gives an unechoed send. The flag rides on the owed wind-down, so a retry after a failed child end withdraws them too. Claude's child end still leaves its unanswered send in doubt. * fix(native-chat): derive the withdrawal of a Codex send whose turn never opened Replaces the flag the Stop carried to the child's end, and its copy on the owed wind-down, with a reading of the journal at the settlement that lands. A Codex child's unanswered send is withdrawn when a person's Stop is in force since it was sent and no turn row ran, or was written, after it; any other end (a turn that opened, a host's close, a crash, Claude) still leaves it in doubt. A retried wind-down reads the same rows, so it withdraws the same sends. * fix(native-chat): a card queued while Stopping runs past the cards the Stop holds A card queued before a person's Stop waits under its pause until Resume. One queued after it, as a message sent while Stopping now is, was stuck behind them too, since the queue never reorders. Such a card was asked for after the Stop, so it runs when the stop lands, past the cards held only by that Stop's pause; a returned card and the restart and /clear pauses still hold everything behind them. Also: the host's own Stopping reading is gated on working, as the published flag is, so a failed Stop's mark never reads Stopping on an idle session; a send that falls back to the lane re-reads the conversation's journal there; and the end-to-end test asserts the queue's pause rather than a per-card field. * fix(native-chat): a paused queue labels only the cards it holds Since a card queued after a person's Stop runs past the cards the Stop holds, labelling every card "paused" while the queue's pause is published misreads that card. The host now marks each card its pause holds (heldByPause, a new optional field), and the desktop and phone label only those. An older host marks none, so a client keeps today's labels; an older client ignores the field. Adds a test of the desktop's own send through the real outbox: while the chat reads Stopping, the request asks the host to queue it and no bubble is drawn, on a host that advertises the queue. * revert(native-chat): defer the per-card queue pause label to the queue's rollout The heldByPause field and its labels are visible only where the host advertises the queued-messages capability, which shipped hosts do not yet do. Deferred to that rollout; the real-outbox send test stays. * fix(native-chat): while Stopping, say and show what a send does where the queue is dark Shipped hosts do not advertise the queued-messages capability, so a message sent while Stopping goes out plain: the host holds it until the stopped turn ends and then runs it as its own turn. The composer still said "Queue a message to run after the stop", and the message was drawn inside the turn being stopped. Now the placeholder reads "Send a message to run after the stop" where the host does not queue sends, and "Queue a message…" only where it does (desktop and phone, all six catalogs). A send this client made that the host has not recorded yet is drawn after the Stopping line while the chat reads Stopping, as a message held behind a running command already is; once the host hands it over it opens its own turn. Client presentation only. * fix(native-chat): keep a send in doubt when a turn was open for it The derived withdrawal read a turn as open for a send only if it still ran or was written after the send. A send steered into a running Codex turn whose interrupt failed met neither once the adapter's end settled that turn ahead of the host's settle, so it read withdrawn, though Codex drains a steer into the running turn and may hold it. A turn that ended after the send was handed over was open for it too: such a send stays in doubt, as before. Pins that case, and that a send made after the Stop, to a child that then dies before its turn opens, stays in doubt. * fix(native-chat): restore the per-card queue pause label Kept after all: a paused queue labels only the cards it holds (heldByPause), which is visible only where the host advertises the queued-messages capability. * fix(native-chat): draw only a send made while Stopping after the Stopping line Every send the host had not recorded yet was drawn after the Stopping line, including one made just before the Stop, which the host steers into the turn; it then jumped up into that turn once recorded. The outbox now marks a send made while the chat reads Stopping, and only those wait after the line. * fix(native-chat): withdraw a Codex send by whether it started its own turn, not by timing Whether a turn was open for a send was read from end times: a turn that ended after the send's handover counted. A send made while a Stop ended the turn is handed over once that turn reads ended, yet Codex's own end for it can arrive later, so such a send whose own turn never opened read in doubt again, and the chat's queue held behind it. The handover already records where the send went: its message joins the turn running then (a steer) or belongs to no turn (it starts its own). Only a send that started its own turn, with none opened since, is withdrawn; one that joined a running turn, or has no recorded place, stays in doubt. The Codex test fake takes an answered interrupt as Codex does, dropping the turn before its end arrives. * refactor(native-chat): move queue-while-stopping to its own follow-up The queued-messages capability is off on every shipped host (#21062), so the parts of this PR that act only when it is on move to a follow-up stacked on this one: admitting a queued card while a Stop holds the session's lane, a card queued after a Stop running past the cards it holds, the per-card pause mark, and asking the host to queue a send made while Stopping. This PR keeps the Stopping state, the host's steer hold, the rule that never steers a turn whose interrupt Codex answered, and what a send while Stopping looks like where the queue is off. * fix(native-chat): leave no Stop row when the Stop took back a send that never ran A Stop on a Codex send whose turn never opened ends the child, and the child's end takes the send back into the composer. The Stop still wrote "Cancellation requested." at the conversation level, so with the send gone it sat under the previous finished turn and read as if that turn had been stopped. A Stop that found no turn running and whose child end took back every send it found now writes no row; a Stop of a running turn, or one that leaves a send in doubt, still does. * fix(native-chat): count a send whose answer was lost when a Stop takes it back The no-row rule counted only pending sends, but the child's end also takes back a send this process left in doubt when Codex's turn/start answer was lost. That case still wrote "Cancellation requested." under the previous turn. Both now read one predicate, so they cannot drift apart. * test(native-chat): pin which sends a Stop's child end can take back A send an earlier process left in doubt is never withdrawn and never holds the row back, and a queued card's send is never counted. * fix(native-chat): hold the next handover while the send ahead opens its turn The delivery loop handed queued messages to the provider back to back. Codex steers a second send into the first one's turn once it opens, and Claude folds it into the running cycle, yet its handover row was written before that turn existed, so it read as belonging to no turn and was drawn ahead of the reply. The loop now hands the next message over only once the send ahead has opened its turn, settled, or been stopped, all read from the journal; each is a commit, which wakes the loop again. The message then goes in scoped to the opened turn. * fix(native-chat): doubt a Codex send whose turn never opened once Codex goes idle A Codex before 0.148 fails a turn before opening it with only an error. The send stayed pending, and the hold behind it waited on it. When Codex reports its thread not running with no turn open, a send answered into a turn it never opened or ended now settles as doubt with the existing idle reason, which releases the hold. No clock: a slow but healthy turn start is never doubted. * fix(native-chat): end an unopened Codex turn on its final error, not at idle, and drop the Stop release Codex publishes the thread idle ahead of turn/completed, and between an aborted turn and the next picked one, so releasing at idle doubted sends in turns Codex did open or was about to. A final error naming a turn Codex never opened (its only end before 0.148) now ends that turn as failed instead, settling its send as a turn/completed failure would. The test fake publishes idle before turn/completed, as Codex does. A Stop no longer releases the hold: the seven rig tests that needed it now settle the stopped send the way the provider does. * docs(native-chat): put each turn-end settlement comment on its own function * test(native-chat): echo the first send before the next in the restarted-child test The test's child admitted the first message and never answered it, which no live provider does; the next send was then held behind a turn still opening. The child now echoes the first message, so the test still checks that the next send restarts nothing. * fix(native-chat): draw a message held behind an opening turn after that turn's live status While the send ahead was still opening its turn, the live turn was taken to be the newest user message, the held one, so its live status drew under the held message and the held message drew above it. While a send is opening, its turn is now the live one, and messages sent after it, queued or not yet recorded, wait behind the live turn as a message held behind /compact or a Stop does. The host's hold and the client's drawing read one shared predicate. * fix(native-chat): keep held messages waiting while Stopping, and leave Retry-only sends in place While Stopping, a message typed then returned early from the waiting rule, so a message held behind the opening turn moved back above the Stopping line. The rule now waits the union of both. A send only the user's Retry sends again is not held by the host, so it no longer waits behind an opening turn; the projection marks it. * fix(native-chat): keep a turn on the send that opened it when Codex echoes a steer first A message held behind an opening turn is steered in moments after that turn opens, and Codex can echo the steer before the send that opened the turn. Both carry the turn's one provider key, so the steer was read as the turn's opener: for that moment the live status moved under it and restarted its clock. While the opener is still in flight ahead of the turn record, a steer into that turn no longer takes it. * fix(native-chat): never anchor an opening turn on a message still queued above its send Two messages queued behind /compact, or sent while Stopping, are accepted above the first send's handover, and the hold makes the first send's turn record land before the second is handed over. The turn fell back to the first unechoed send ahead of its record, which was the queued one, so its live status moved under it. A send still queued is not in flight. The phone keeps no outbox, so its frame tests now draw only recorded rows, through the phone's own fold rather than the desktop's transcript order. * fix(native-chat): read the host's Stopping beside main's startup phase Main now reads only the startup phase from the status feed and no longer publishes which child is starting. The chat reads the host's Stopping from its own hook beside it, and the Stopping bridge test mocks the execution-host lookup main's owner resolution now calls. * test(orchestration): open the working send's turn before a `now` send joins it The rig's working send was handed over with no turn record, so the running turn the test names never existed; the hold rightly kept the `now` send until that turn opened. The setup now opens it as a provider does. The assertions are unchanged. * test(native-chat): pin the phone's own echo of an accepted send behind an opening turn The phone keeps no outbox, but it does show its echo of a send the host accepted until that send's row arrives, and the shared waiting rule moves that echo behind a turn still opening. The phone test now builds its list as the phone's view does, echoes included, and covers it. * test(native-chat): read the outbox reconcile from where main moved it * test(native-chat): pass the projection's options after main's rejected-in-place rows * fix(native-chat): a retried message no longer waits behind a later Stop Retry dropped the Stop it had outlived but kept the mark that it was sent while a Stop was ending a turn, so a retried message waited behind whatever later, unrelated turn a Stop was ending. Retry is a new send: drop that mark too. * fix(native-chat): word a send after a Stop by whether this send will queue The 'queue a message to run after the stop' placeholder read the host's queue capability alone. A send queues only when the host queues and this send asks it to: the queue setting is on and no pending prompt blocks the queue. Desktop and phone now word the placeholder from that same decision their send uses. * test(native-chat): one test per case for the words of a send after a Stop * refactor(native-chat): the dictation hook owns the composer's dictation state Keeps NativeChatComposer within its line limit after the Stop props and main's /context answer both landed in it. * refactor(native-chat): name the dictation hook for what it owns now * fix(native-chat): a Stop's note says it took once a joined close proves the exit A session-ending Stop whose child's end failed revises its note to 'unconfirmed'. Since main's #24862, the next Stop joins that close rather than stopping again, and wrote no note, so a close that then proved the exit left 'unconfirmed' under a turn that ended. The close now carries the note it settles, and its proven exit revises it to 'Cancellation requested.'. A join that fails again leaves it unconfirmed. * fix(native-chat): a proven turn end says a Stop's unconfirmed note took Replaces the note carried on the child's close. Every settlement that ends turns interrupted on a proven exit, live or after a crash, also revises an unconfirmed Stop note on those turns to 'Cancellation requested.', found by the note's turn scope, in the same batch. A note a Stop wrote before its turn showed is re-keyed onto the running turn when it becomes unconfirmed, in one batch, so that end finds it. Known limit: with no turn open yet, the note keeps its key and no turn's end revises it. * test(native-chat): a Stop's unconfirmed note says it took when the agent exits on its own * test(native-chat): a message that joins a Stop's unproven Claude close goes to the resumed child, the next waits for its echo Covers the case main's #24862 retired with its unproven-stop test: the first message after an unproven close joins it and reaches the resumed child, and a second waits for that message's turn to open. * test(native-chat): name the Codex handle as main's opaque handle does * test: restore the provider handle import the main merge dropped * fix: derive Stop note wording from interrupted turns * test: name the raw replay case for what it covers * refactor(native-chat): move the waiting-slot split into its own hook * test: give the opening-send hold host the agent registry main now requires * test: follow main's chat font-size rename in the stopping tests * test: follow main's chat font-size change in the opening-send test * test: follow main's single live-line value in the Stopping tests * test: give the android live-line fixtures the stopping field * chore: keep the session host under its line limit after the main merge * chore: keep the composer test and the phone chat view under their line limits after the main merge The Stop control now disables itself while Stopping, so the composer passes the flag through and its test file stays as main has it. The phone chat header's Stop moves to its own component. * perf(native-chat): read a Stop note's fields before parsing its key on every snapshot Every snapshot projects each item through the Stop-note read, and each new snapshot rebuilds the index of Stop notes by turn. Both parsed every item's key first; they now check the row's kind and turn scope (and, for the projection, its unconfirmed-stop failure) before the parse. Every Stop note is a status row, so what each finds is unchanged. * fix(native-chat): a Stop ends the hold on the next message, even when the stopped send's turn never opened * test: a Stop's note ends the hold on the next message * fix(native-chat): only a Stop that took ends the hold; a refused or unconfirmed one leaves the turn opening * fix: import the moved Stop-note helpers where the host module uses them; type the test's failure note * test(native-chat): pin that a taken Stop's note lands after the turn the provider opened before answering it * test(native-chat): give the opening-send hold test's runtime the launch arguments main now requires * fix(native-chat): a message a Stop takes back while the turn ahead opens stays after that turn, with its stop row; move the rows that wait behind the live turn into their own module * test(native-chat): run the two opening-send hold tests, which open a real journal database, in the Node runtime project * test(native-chat): with a send still opening its turn, a starting child gets only the first message; a failed start still rejects both in its words















What this fixes
The problem. When you press Stop on a native Claude chat, close the tab, or Orca puts an idle chat to rest, Orca ends the agent's process. It must confirm the process is gone before it starts a new one. #24333 handled a failed confirmation by keeping a separate record next to the chat saying "a stop is still owed". Your next message was held under a note until a later retry confirmed the exit. Review of that design found three problems:
This replaces #24333's hold. It also supersedes #24362, which added a second notification to cover the first problem.
ELI5
Before, a Stop that couldn't confirm the old agent quit left a sticky note saying "stop still owed", and your next message waited behind that note. Now there is no note. The stop is simply still in progress, and anything that needs the agent waits for that same stop to finish. The moment the old process is seen to exit, it is cleaned up, whether anyone is waiting or not. If it still can't be confirmed gone, your message is sent back with "send again" instead of being held, and sending again tries the stop again.
What Changed
Before (for you):
After (for you):
Mechanism.
child.close). That record lives only as long as the child, so nothing outlives the process it is about.owesProviderChildWindDownis deleted, along with everything fix(native-chat): after a Stop whose exit can't be confirmed, the next message retries the stop instead of failing #24333 built around it: the retry entry points, the wait note and its "already waited" rule, the ask and failure positions, and the stop's own wake.structured-agent-session-child-exit.ts), whether the exit was asked for or not.expected. This covers a process that exits after its close gave up. The adapter then finishes the close through the same path as any close, so the child's background work is published as ended.expectedchanges only what the chat is told: the stop's cause, the end stamped where the stop was asked for, no crash row, and no reader re-baseline.previousExitUnverifiablefailure kind and refusal reason stay in the shared lists and catalogs, because persisted rows and fix(native-chat): after a Stop whose exit can't be confirmed, the next message retries the stop instead of failing #24333-era hosts carry them.Why
restartFailedkind, carrying a refusal reason that is already listed.Differences from the common pattern
structured-agent-session-eviction.tson main).notResumable.Linked Issue
Replaces #24333's hold; supersedes #24362.
Visual Proof
Live QA on head
6cf2520ecce, in hidden dev builds driven over the browser debugging protocol, with isolated profiles. It tests what a message does right after Stop, two quick messages after Stop, quitting during a turn, and relaunching to resume.Real Claude (macOS, M4 Air). A Claude Stop ends Claude's process, so this is the case the change is about. All four scenarios passed.
Stop during a running command, then send right away: the message goes out as its own turn to a fresh Claude, with no waiting note and no error.
Stop, then two quick messages: never two Claude processes at once, and both replies arrive in order.
Quit mid-turn left no Claude process behind. Relaunching the same profile kept the history, and the next message was answered.
Real Codex (macOS). All four scenarios passed. A Codex Stop that interrupts the turn keeps the same Codex process, so this shows no regression rather than the new exit path.
Not shown live: the "Orca couldn't stop Claude's previous process. Send your message to try again." rejection. It needs a process that survives a forced kill, which a test stand-in can't produce on macOS. Host-level tests cover it. Windows was not exercised live.
All QA screenshots: Claude, Codex.
Testing
New host-level tests in
structured-agent-session-claude-stop-exit-ends-record.test.tsreplace #24333'sclaude-unproven-stop-sendtest. They run the shipping Claude adapter and host against a fake connection that, like the real one, refuses writes once a close begins:previousExitUnverifiable. Once the exit is proven, the pick is kept for the next start.Test 6 also checks that the failed kill is logged (
provider-close-unproven). Both connections have a test for a close after an unproven one: it runs the stop again (Codex sends SIGKILL a second time), proves the exit, and meanwhile refuses input (claude-stream-json-connection-close.test.ts,codex-app-server-connection.test.ts).Codex (
structured-agent-session-codex-stop-row.test.ts, the existing Codex Stop rig):Other new or changed tests:
structured-agent-session-lifecycle-delivery.test.ts: a requested close ends while another chat's crash recovery is still blocked.expected.claude-stream-json-connection-close.test.ts).structured-agent-session-runtime-teardown.test.ts).Ablations. Each one reverted one piece and ran the matching test, and every one failed. The latest round's set:
The round before:
The previous round's ablations still apply: the start joining the close, the close shared between callers, the exit report ending the record, the start re-deriving the lease, the unverifiable wording, model changes joining the close, the root-exit and bookkeeping verdict mapping, quit including closing children, the reaper joining, and both connections reporting
expected.Removing the wait before the kill. The stop no longer waits up to 1 second for its stop-event write before killing. No ordering failure appeared with the wait removed. The two stop-event tests read the journal the moment the close began, so they now let the already-queued row write finish first.
Suites.
agent-session-wire,src/main/runtimeandsrc/main/claude, every test file insrc/main/codex, and the journal-database test: 486 files and 4820 tests passed. One file timed out under machine load (codex-app-server-client.test.ts, which this PR doesn't touch) and passes run alone. The lease release and record tests pass (16 files).tests/e2e/cross-version-wireunit test files passed on the previous round, run one at a time. At this head, the stop-event downgrade and published-field-shape files pass. Incross-version-agent-session-wire, 18 of 19 tests pass; the release-checkout test timed out at its 180-second limit with the machine at a load average of about 106, and a re-run timed out while building the checkout. This round changes nothing on the wire; CI runs that file.Typecheck:
pnpm tc:nodeandpnpm tc:webare clean at this head.Lint: oxlint, the anti-slop config and oxfmt are clean on the changed files.
check:code-quality:changedpasses, and the max-lines ratchet reports no new bypasses.Platforms: logic tests on macOS. Not exercised on a live Claude or Codex, Windows, Linux or a real SSH host.
Review
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
structured-agent-session-child-close.ts(131 lines) for the join, andstructured-agent-session-child-exit.ts(218 lines) for the one exit handler, which replacesstructured-agent-session-unexpected-exit.ts. The wait-row file and the teardown deadline file are deleted.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)