Repository navigation
fix(native-chat): settle a reply cut off by a server restart - #26460
Conversation
…ot flushes Shared-timeline checkpoints stamped the coalescer flush or retry time as the provider's latest proof of life, so a turn whose provider died before a delayed flush settled with an inflated duration after a host restart. Carry each delta's receipt time through the coalescer and timeline lane.
A reply cut off by a server restart could show "Worked for 0s" because recovery ended the turn at the last heartbeat or the reply's first timestamp. End it at the latest time this owner's output was saved to chat history instead, derived in the existing journal fold from the row save time. Drops the per-frame receipt plumbing through every provider and buffer.
Mark journal-open and connection-cleanup revisions with the existing recovery annotation so an interrupted turn does not include host downtime. Preserve buffered provider output and advance restart fixtures to the restart clock.
Review summaryProblem: when the SSH server restarts mid-reply (a crash, an update, or a manual restart), the reply is cut off. After the restart, the cut-off reply could show "Worked for 0s". Recovery ended the turn at the last heartbeat or at the reply's first timestamp, both of which can be well before the last output. User-facing change: a reply cut off by a restart now shows roughly how long it actually ran. Main already marks it Interrupted; that part is unchanged. How: the turn now ends at the last time the agent's output was saved to chat history. That time comes from the save time the chat history already records, read in the same pass that rebuilds the chat on open, so nothing new is stored. It is bounded by when the old server process was confirmed gone; losing contact alone never counts as that confirmation. Saves made by the server itself during recovery, such as marking a dead chat's background tasks unknown or closing out a lost connection, don't count as output. Review rounds:
Accepted trade-off: output that sat in a buffer before it was saved counts until it was saved, so under heavy load the time can read slightly long. It never shows 0s for a reply that streamed. Not verified: a real agent dying with a real SSH server, a rendered desktop, Linux, Windows or the phone. The tests use the real chat history and server code with scripted agents. Live SSH QA across all the SSH chat fixes is still to come, and screenshots will go in the PR body before this is marked ready. CI: the one red check, "static analysis", is a line-limit lint error that was on main when this ran (fixed on main by #26648). It will clear on the final sync with main. |
When the managed server died mid-reply and a new one started on the same host, the cut-off turn settled `unverifiable` with no end whenever the old provider's identity could not be proven gone, and nothing ever revised it: the chat showed no turn status and the sidebar said "unconfirmed". The new server opens the chat records holding the host's instance lock, so every runtime that wrote them before is gone, and every provider it held lost its pipes with it. Record that at load (the fences those runtimes granted, the loaded owner's last renewal, how its runtime ended) and let the stale settle end those runtimes' running or unverifiable turns as interrupted at the owner's last saved output or renewal, with the existing Orca-stop row. Lease adjudication and the live / unverifiable / exited owner verdicts are unchanged.
…settles Only an acquisition's open may skip it; a reader open without the store would leave a replaced server's turns unverifiable.
Review summary (final review, head 876915c, plus round-7 small fixes at 414c424)Problem: on the SSH server, if the server process was killed or restarted while Claude was replying, the restarted server never settled that reply. The chat showed the cut-off reply with no status, and the sidebar showed it as unconfirmed, forever. The server waited for proof that the old agent process had died, and after a restart that proof is unavailable. User-facing change: a reply cut off by a server restart now shows as Interrupted, with a duration that ends at the agent's last saved output, so server downtime isn't counted as work. You can continue the chat normally. Chats already stuck this way are fixed the next time the server starts. How: the server only runs while it holds its single-instance lock. When a new server instance loads the chat records, any reply owned by an earlier instance on the same host is from a server that is gone. A surviving agent can't write to the chat, because agents talk to the server over pipes that die with it. Those replies settle as Interrupted when the chat is opened. Nothing new is stored: the list of replaced instances is rebuilt in memory on every start. This matches the common pattern of settling in-flight work when the instance running it is replaced. Review rounds: six earlier rounds covered the duration-only version of this fix. Live SSH QA (Q8) then showed the reply stayed unconfirmed after a real restart, so round 6 changed the evidence to "the instance was replaced".
Not verified: a live SSH re-run of Q8 on this head (running next on the combined test build; screenshots go in the PR body before ready), WSL, native Windows or Linux hosts, a real Claude login, the rendered UI, mixed old/new peers. |
… closed A reply cut off when a remote host's Orca server stopped read "Orca on <host> was closed while this response was in progress." Nobody closed anything there: the server stopped and Orca started it again before the row could show. For a chat on a remote environment, cause quit now reads "Orca on <host> restarted while this response was in progress." The desktop's own quit keeps "was closed". Renderer-only; no wire change.
Review summary addendum (rounds 8–9, head cf91fbc)Why another round: live SSH QA killed the SSH host's Orca server mid-reply. The fix worked: the reply was settled with the time worked, Continue appeared, and a follow-up worked. But the reply's notice read "Orca on was closed while this response was in progress." That wording comes from a desktop quit, and here nobody closed anything. The PR body also overstated what users see. Changes:
Independent review of round 8 (head a6fd5c6): no P0–P2.
Its P3s are fixed in round 9: the "restarted" wording, the old sidebar label quoted wrongly in the body, a stale comment, and unlabelled differences. The coordinator checked the round-9 diff: wording, locales, comment and test only. Checks on cf91fbc: 62 targeted renderer tests pass, the web typecheck passes, and the local CI checks pass 22/22. Hosted CI is running. Still to do before ready: a live SSH run showing the new wording, with screenshots in the PR body. At the final sync with main, resolve small conflicts with main's #26544 in the notice-row files, keeping both changes. |
…-orphaned-turn-settles # Conflicts: # src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx # src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx # src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx
…-orphaned-turn-settles # Conflicts: # src/main/native-chat/agent-session-journal/journal-row-writer.ts # src/main/native-chat/agent-session-journal/journal-store-collaborators.ts # src/main/native-chat/agent-session-journal/journal-store.ts # src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
…utput rig Main's #26502 made Grok declare compaction; the rest-test rig adapter has no compact, so the registry refused it before any recovery case ran.
…-orphaned-turn-settles # Conflicts: # config/scripts/vitest-sqlite-runtime-files.mjs
Review summary addendum (main syncs, head 8bffceb)Why more work after round 9: main merged #26664, "settle a dead agent's leftover work in one write that always fits". It rewrote the same code this PR changes, the code that settles replies a dead or replaced server left unfinished. What the merge did:
Independent review of the merge (head 9c6a3ca): no P0–P2.
Its two P3s are fixed: a stale code comment (9997d44) and the PR body's wording for the narrow case. Live proof: the managed-SSH run above (Visual Proof) shows the cut-off reply settled, with the time worked, the "stopped" line and Continue. Latest sync (8bffceb): one trivial conflict, in a list of tests that run under Node, with both sides kept.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (50)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review. 📝 WalkthroughWalkthroughJournal writes now retain save timestamps and track provider activity by owner fence. Session recovery uses saved activity and replaced-runtime evidence to settle interrupted turns and create stop notices. The change adds restart and replacement tests, plus remote-host status and localized wording for Orca stop notices. Several renderer expressions and the background-terminal call were reformatted without changing their behavior. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The reviewed restart behavior is consistent with marking a reply interrupted when its former host can no longer write to the chat. No merge-blocking issue was established. Pre-merge checks |
|
* fix: preserve streamed turn duration after host restart * test: run restart duration regression in Node * fix: preserve provider receipt time in journal updates * test: verify provider receipt in Claude ownership assertion * fix(native-chat): time interrupted turns from real provider output, not flushes Shared-timeline checkpoints stamped the coalescer flush or retry time as the provider's latest proof of life, so a turn whose provider died before a delayed flush settled with an inflated duration after a host restart. Carry each delta's receipt time through the coalescer and timeline lane. * fix(native-chat): derive interrupted-turn duration from saved output A reply cut off by a server restart could show "Worked for 0s" because recovery ended the turn at the last heartbeat or the reply's first timestamp. End it at the latest time this owner's output was saved to chat history instead, derived in the existing journal fold from the row save time. Drops the per-frame receipt plumbing through every provider and buffer. * fix(chat): exclude host recovery revisions from provider activity Mark journal-open and connection-cleanup revisions with the existing recovery annotation so an interrupted turn does not include host downtime. Preserve buffered provider output and advance restart fixtures to the restart clock. * fix(native-chat): interrupt turns a replaced server left running When the managed server died mid-reply and a new one started on the same host, the cut-off turn settled `unverifiable` with no end whenever the old provider's identity could not be proven gone, and nothing ever revised it: the chat showed no turn status and the sidebar said "unconfirmed". The new server opens the chat records holding the host's instance lock, so every runtime that wrote them before is gone, and every provider it held lost its pipes with it. Record that at load (the fences those runtimes granted, the loaded owner's last renewal, how its runtime ended) and let the stale settle end those runtimes' running or unverifiable turns as interrupted at the owner's last saved output or renewal, with the existing Orca-stop row. Lease adjudication and the live / unverifiable / exited owner verdicts are unchanged. * fix(native-chat): require the record store for any journal open that settles Only an acquisition's open may skip it; a reader open without the store would leave a replaced server's turns unverifiable. * fix(native-chat): say a remote host's Orca restarted, not that it was closed A reply cut off when a remote host's Orca server stopped read "Orca on <host> was closed while this response was in progress." Nobody closed anything there: the server stopped and Orca started it again before the row could show. For a chat on a remote environment, cause quit now reads "Orca on <host> restarted while this response was in progress." The desktop's own quit keeps "was closed". Renderer-only; no wire change. * fix(native-chat): say a remote host's Orca stopped, true for a shared desktop too * test(native-chat): register Grok without compaction in the recovery-output rig Main's #26502 made Grok declare compaction; the rest-test rig adapter has no compact, so the registry refused it before any recovery case ran. * chore(native-chat): say a replaced runtime also revises an unverifiable turn (cherry picked from commit b44a579)
ELI5
When the server running your chats (for example the Orca server on an SSH host) restarted in the middle of a reply, the cut-off reply came back with no status at all: no time worked, and the sidebar said "Couldn't confirm". It never cleared, not even after the next message.
Now that reply is settled. It shows how long the agent worked (up to its last saved output, so the server's downtime never counts), a muted line saying Orca on that host stopped while the response was in progress, and Continue. The sidebar no longer says "Couldn't confirm". Chats that an earlier version already left stuck this way heal the next time the server starts and the chat is opened.
Temporary: until #25670 lands, the turn bar reads "Worked for N" and the sidebar and tab show the same red Failed that a reply cut off by quitting the desktop app shows today. Follow-up: #25670, after which they read Interrupted.
What Changed
Before: after the SSH server restarted mid-reply, the cut-off reply showed no status. This happened whenever Orca could not prove that the old agent process was gone (for example a process it could not inspect, or one that would not exit when signalled). The turn was left unconfirmed (the sidebar said "Couldn't confirm") and nothing ever settled it.
After: the cut-off reply is settled as interrupted. On screen today that is "Worked for N", the muted line "Orca on stopped while this response was in progress.", and Continue; a follow-up message works as usual. The time worked ends at the agent's last saved output (or its last heartbeat, if that is later), never at the restart time, so the server's downtime never counts as work. Chats an earlier start already left showing "Couldn't confirm" are healed the same way on the next start.
Copy change: that muted line used to say "Orca on was closed while this response was in progress." For a remote host that reads as if someone closed Orca, which is often false: a server Orca runs on an SSH host, or a headless server, was stopped, not closed by a person. A chat on a remote host now says "stopped" for this case, which is true whether that host is a managed server, a headless server, or another desktop shared through "Share this host" whose user quit it. When the desktop app itself is quit mid-reply, its line still says "was closed". This is a desktop-only wording change (the stored row and what the server sends are unchanged); it is translated in every shipped language. The phone app shows the server's own generic words for this row and is unaffected.
How: a new server opens the chat records only while it holds the host's single-instance lock, so every earlier server that wrote them has exited, and every agent those servers ran lost its connection to the chat with them. When the new server loads the records, it notes (in memory, never saved) which ownership generations belonged to those earlier servers. When a chat's history is next opened, any turn they left running, or that an earlier start could only mark unconfirmed, is ended as interrupted, with the stop line attached.
Unchanged:
stale-session:notice id prefix, so older desktops render it.Earlier in this PR: buffered agent output is timed by when it was saved, and the host's own recovery writes don't count as agent output. Both feed the "time worked" above.
Why
On macOS and Linux, an agent process can outlive its server for a few seconds while its supervisor stops it, but its output pipe goes to the dead server, so nothing it does reaches the chat. Requiring proof that each agent process is gone left the chat silent whenever the host couldn't produce that proof. The common pattern settles a replaced server instance's in-flight work as interrupted, keyed on the server instance's identity rather than per-process proof, and so does this change.
Intended: known narrow case. If an earlier start released the chat's owner without proof and then failed before it settled the turn, the owner's last heartbeat is overwritten by that release. If the turn also saved no further output, it ends at its own start ("Worked for 0s"); a turn Orca opened itself (for example a command turn) instead settles with no end time and shows no duration, because Orca never invents an end time. The time shown is a lower bound; the record holds nothing better, and this PR deliberately adds no stored field for it. It is safe to keep: it needs a failed start in between, it only under-counts time worked, and the common path (no release in between) keeps the heartbeat.
The doubled partial reply seen in an earlier QA run came from the QA stand-in agent, which replays its whole saved transcript, including assistant rows, on every resume. It is not changed here.
Linked Issue
Internal structured-chat restart repair (U30); no existing issue linked.
Visual Proof
Live run on a managed SSH host (Linux container host, desktop on macOS, a credential-free stand-in Claude), with this PR's changes combined with the other SSH chat fixes. A long reply was streaming when the host's Orca server got a stop signal; Orca restarted the server. The cut-off reply shows how long the agent worked, the partial text (through chunk 28 of 60), the muted line "Orca on QA SSH run4 stopped while this response was in progress.", and Continue. A follow-up message then completed normally.
Automated tests also drive the production host with real SQLite chat history and check the result through the same projection the chat UI renders: one interrupted turn, one stop line with Continue, and the time worked.
Testing
On macOS, with
env -u ORCA_USER_DATA_PATHand--maxWorkers=2, on the previous head: 179 tests passed across 20 explicitly listed files (the 13 test files this PR adds or changes, plus the 7 acquisition tests that open a journal without the store). An independent review run on an earlier head also passed 572 tests across 60 restart-related files. For the copy change, on this head: 62 tests passed across the 3 renderer test files that render the stop line or build what it is told (NativeChatNoticeRow,NativeChatInterruptedContinue,native-chat-transcript-slots).What the new and changed tests cover:
Node typecheck passed on an earlier head (no main-process code has changed since); the web typecheck passes on this head.
orca-ci-checks --no-typecheckon this head: all 22 local static checks pass (lint, code quality, localization, file size).Not verified: WSL, native Windows and Linux runs, live mixed-version peers, and how long a Windows agent keeps working after Orca dies. No Claude structured-session files change, so the real-Claude run is not required.
Review
Reviewed in rounds; the latest found no blocking issues. It confirmed the host lock and single record store mean every earlier writer has stopped, that no surviving agent can write to the chat, that this server's own turns can never be caught (its fences are always above the loaded ones), and that terminal agents and other hosts' chats are excluded. This PR stays a draft until the live SSH screenshots are attached.
Agent skill upstream boundary
Notes
The same host chat history serves local desktop, SSH, and WSL execution, and folder workspaces as well as git worktrees; the settle does not read workspace kind. Remote chat records name their own host as
local, so the fix applies on the managed SSH server. The verdict comes only from the execution host's own lock and server identity, never from a client losing contact. Older rows keep their existing timestamps; no missing save time is invented.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)