Skip to content

fix(native-chat): list chat statuses after a restart without opening any chat - #26439

Draft
brennanb2025 wants to merge 7 commits into
mainfrom
brennanb2025/chat-status-in-agent-store
Draft

brennanb2025 wants to merge 7 commits into
mainfrom
brennanb2025/chat-status-in-agent-store

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

When Orca starts, it used to open and replay the full history of every chat that has a tab, just to learn each chat's status. That made startup slow, and statuses filled in late. Now Orca saves each chat's last status the same way it already saves terminal agents' statuses, and shows the saved status at startup without opening anything. A chat that was mid-reply when Orca quit, crashed or updated reads "Interrupted" (or "Couldn't confirm" when Orca can't prove the agent stopped). It never reads as still working.

What Changed

The problem

  • On startup, the chat tab list waited until every listed native chat's history had been opened and replayed. That replay was the only way any chat status reached the sidebar, the phone, paired desktops or orca worktree ps. With many tabs, startup was slow and statuses arrived late.
  • A turn cut off by a quit, crash or update was only settled (marked as ended) when someone opened that chat.

Before and after, for the user

Before After
Startup Tab list waits for every chat's history to replay Tab list comes from saved records; no history is opened
A chat that was idle Status appears once its replay finishes Its saved status shows immediately
A chat that was working, or waiting on a permission prompt or a question Showed its real verdict only after the replay settled it Is opened and settled at startup, and its row shows only the verdict that settle wrote: Interrupted when the agent's death is proven, Couldn't confirm otherwise; never "working" or "waiting"
Opening a chat Already open Opens and replays on first use, as a closed chat already did

The mechanism

  1. Save. The agent status store's file (last-status.json) already saves terminal agents' rows. It now also saves each native chat's last status summary, under its own structuredSessions key.
    • The agent status store saves each chat as it takes in that chat's row, and only when what a restart would show changes: a status change or a verdict change. Streamed text is never a reason to write. It compares against what it loaded, so restoring statuses at startup writes nothing.
    • It is written at once, so a crash right after a turn ends still finds the end, and it is flushed when Orca quits.
    • It is not tied to the terminal-agent "status hooks" setting, because native chats have no hooks. With that setting off, the terminal rows in the file are written back exactly as they were read.
    • Only the history's half of the status is saved: status, verdict, latest prompt, last reply preview and the two clocks. The chat record already holds the rest (agent, workspace, name, model, provider session, launch directory), so a restored row reads those from the record.
    • An entry a newer build wrote, or one this build cannot parse, is written back exactly as it was until this build saves that chat. A file this build cannot read at all is never written over while status hooks are off.
  2. Show. After the startup lease check (which records proof that each previous agent process died), the host's status feed shows each listed chat's saved summary, but only for chats that were settled (idle) when saved.
    • The agent status store's row is derived from that summary by the existing ingest, so orca worktree ps, the power-save blocker and stats all see an idle row, never live work.
    • Showing a saved status does not count as a chat going idle, so it starts no mail, naming or rename work.
  3. Settle cut chats at the restart. A chat saved as working or waiting shows nothing saved. It is opened at startup, through the existing restore path, so its history settles right away, and the row it publishes is the verdict that settle wrote. The tab list waits for this. A cut chat with no tab is closed again afterwards.
  4. Re-drive parked orchestration mail. Startup also opens the listed chats that undelivered orchestration mail is waiting on, found by the same pending-mail scan the database's restored-mail re-send uses, and nothing else. Each of those chats' existing idle step then points the mail again. See "Parked mail" below.
  5. List tabs without replay. Tabs are listed from saved records: each listed chat that has a record and a written history. The restore uses the same check, so a chat with no history gets no tab and no status row. This is the minimal version of fix(native-chat): list chat tabs at startup without opening every chat's history #23978's listing, redone on current main; fix(native-chat): list chat tabs at startup without opening every chat's history #23978 will be closed as superseded.
  6. Removed:
    • The "never persist native chat rows" rule (the serializer skip and its persist guard).
    • The startup replay of every listed chat.
    • The "unopened tab" bookkeeping that only existed because tabs came from opened chats.
    • The host's public restoreReadableSessions, which no production code called; tests reach it through collaboratorsForTests().

Every saved entry has a way to die: its chat's record is deleted, the host lets go of the chat (its tab closed), the chat is unlisted at startup, or its next save replaces it. There is no age limit: a chat untouched for weeks keeps its status, and a cut chat is settled however long Orca was closed.

Decisions

  • A. The verdict follows the existing rule. Interrupted when the agent's death is proven; Couldn't confirm otherwise; never working or waiting.
    • On Windows a crash can leave the agent running: there is no supervising process or job object for these agents, and startup does not signal a saved process id there.
    • Startup releases such an agent without proof that it died, so its history settles "unverifiable" and the chat honestly reads Couldn't confirm.
    • On macOS and Linux a supervising process stops the agent's process group when Orca exits, crashes included, so the death is normally proven and the chat reads Interrupted.
  • B. Settle only cut chats, eagerly. Right after the lease check, only the chats saved as working or waiting are opened and settled, and their row comes from that settle, never from the save. Every other chat waits until it is used.

Parked mail

With the startup replay gone, mail parked for an idle chat could strand.

  • The orchestration database already re-sends every undelivered mailbox 2 seconds after it first opens. That re-send does reach a chat that isn't open.
  • On main, the replay's idle step was often what opened the database.
  • The real hole: if that re-send runs before the chat host exists, it parks the mail until the chat's next activity, which never comes.
  • Startup now opens exactly the chats the database's pending-mail queries name, and their idle step points the mail again.
  • Mail for a chat whose tab was closed still waits until that chat is reopened, exactly as on main. Delivering it would run turns nobody can see.

Known gaps

  • A crash right after a turn starts, before its save. The save follows the history write in the same synchronous step, so the window is tiny. If it is hit, the chat lists with its previous verdict until it is opened, and opening it settles the cut turn. A crash right after a turn ends is safe: the saved "working" makes startup open and settle the chat from its history.
  • A cut chat whose history cannot open lists with no status until it is opened; its read says why.
  • Mail for a chat whose tab was closed waits until it is reopened, as on main.

Why

  • Why not a per-chat status table written on every history append (feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233, closed)? That added a write to every append, and a new table. This adds no table: it reuses the agent status store's existing file and the host's existing status feed, and writes only on status changes.
  • Why not keep the startup replay? It made startup cost grow with every open tab, and it still left statuses late. Saved statuses show immediately. The only chats opened at startup are the few a restart cut or that have mail waiting.
  • Why save the status summary, not the store row? The sidebar, phone and paired desktops read native chat status from the host's status feed, not from the store. The store row is derived from the feed's summary, and that derivation cannot be reversed.

Differences from the common pattern

  • Intended: the list status is a saved copy of the history's summary, kept in the agent status store's file, rather than a projection maintained from the event history itself. The history stays the source of truth: a chat that was mid-turn shows nothing saved and is settled from its history at startup, and any chat's first open replaces the saved copy. A history-maintained projection is what the closed feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233 built, and it was rejected as too heavy.
  • Intended: that copy is written on status changes, after the history write, not in the same transaction. The cost is the small crash window listed above, which the chat's next open settles.
  • Intended: a death Orca cannot prove reads "Couldn't confirm" rather than "Interrupted". On Windows Orca has no supervisor for these agents, so one may still be running after a crash, and calling it interrupted would be false. This is the verdict the history already records.
  • Same as before, not new: on a headless Orca server, startup settling runs when a client first lists the server's tabs, which is when the old replay ran.

Linked Issue

Supersedes #23978's listing (included here). Replaces the closed #24233.

Visual Proof

Pending: live QA in the rendered app (sidebar, phone and paired desktop after a restart) is next and will be attached here. The behaviour is covered by the end-to-end tests below in the meantime.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New tests:

  • Startup with a crashed chat (real host, journal, lease and agent status store over a real last-status.json): a chat saved as working or waiting nine days ago is settled at startup, and every row it shows carries the settled verdict and the history's prompt, never what was saved.
  • Agent may still be running: with a Windows platform stub, a chat whose agent is not proven dead reads Couldn't confirm, as its history does.
  • Idle chat: saved nine days ago, it lists with its saved status and the record's own fields, nothing opens, no mail or naming work fires, and the file is not rewritten. Its own publish replaces the saved one once it opens.
  • Edge cases: a chat opened before the restore keeps its live status, a never-written chat gets no tab, and a chat whose history can't open still lists.
  • Saving: a save happens on status and verdict changes and never per streamed delta; nothing loaded is re-saved; a quit flush works with the hooks setting off; an old entry is kept; a newer build's entries are written back verbatim; a file this build cannot read is not written over with hooks off; an older file loads.
  • Parked mail end to end: a dispatch worker chat goes idle, mail lands while no host is up, and the database's own re-send parks it. After restart the chat receives the pointer with no user action.
  • Downgrade, cross-version: the last release (v1.4.222) reads a file holding chat statuses with its terminal rows intact and the chat statuses ignored, and this build reads what that release writes.

Each rule was ablated, by breaking the line and checking that its test goes red. All 27 ablations went red, including: no age limit, cut chats showing only their settle verdict, no re-save at startup, record-owned fields from the record, one filter for list and restore, keeping a newer build's entries and file, save-on-change, no save per delta, the immediate write, the quit flush, loading with hooks off, no mail on a saved status, live publish replacing the saved one, only cut and mail-owed chats opening, closing unlisted cut chats, listing from records, the parked-mail open, and the old-build file format.

Every test file that directly imports a changed module was run (366 files), plus the cross-version suites one at a time.

  • Deliberately excluded: files that launch real agent CLIs or point at a real home directory. These are everything under src/main/claude/, the qoder real-CLI test, the Codex real-PTY test and the Claude structured-session integration test. CI covers them.
  • Platforms: macOS only. Windows and Linux behaviour was checked by reading the code plus a Windows platform stub.

Lints: full oxlint, the changed-code quality gate, native-plugin, anti-slop and type-aware oxlint on all changed files. Typecheck is clean for node, web and cli.

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Author: @BrennanKB5

  • Security: the file is still written owner-only (0600). Saved summaries carry the same fields the status feed already sends.
  • Cross-platform: covered above. The Windows case reads Couldn't confirm.
  • Remote SSH: native chat is not offered over SSH or WSL. Each execution host keeps its own file, and a headless server saves and restores its own.
  • Mobile and paired clients: they read the same status summaries over the same feed. No wire change.
  • Backwards compatibility: an older build ignores the new key and drops it on its next write. Its chats then list without a saved status until opened.
  • Performance: startup opens only cut chats and chats with mail waiting. A save is one small file write per status change.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…any chat

Each native chat's last status is saved in last-status.json beside the CLI
agents' rows, on status and verdict edges only. Startup shows those saved
statuses, settles at once only the chats a restart cut mid-turn (with the
same verdict the journal's open writes), and lists chat tabs from saved
records instead of replaying every listed chat's history.
Startup no longer replays every listed chat, which was what re-drove
orchestration mail parked for an idle chat. After the lease check it now
also opens the chats the orchestration database's pending-mail scans name,
and nothing else, so each one's idle edge points its mail again.
…aved statuses until their chat goes

Addresses review findings on the saved native chat statuses.
- No age limit on a saved chat status: a chat's record, its tab and its
  next save bound each entry, so a week-old chat keeps its status and a
  week-old cut chat is still settled at startup.
- A chat a restart cut shows nothing saved; its startup open publishes the
  verdict its own settle wrote, so the row never shows a different one first.
- The agent status store saves what it ingests and compares against what it
  loaded, so restoring statuses at startup writes nothing.
- Only the journal's half of a summary is saved; the record supplies the rest.
- One restored-mailbox scan, one filter for the tab list and the restore,
  and a newer build's entries and files are written back untouched.
Neither is part of this change; restoring them keeps the branch off main's edits.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary (head 19c2c43)

Verdict at f6c45b2: fix first. The reviewer agreed with the core shape: statuses saved in last-status.json under their own key, tabs listed from records without a replay, and only the chats a restart owes opened right after the lease check. All findings below are fixed in 9673cd7. 19c2c43 then restores two unrelated files that a formatter pass had reflowed, which also cleared a conflict with main in one of them.

Findings and fixes

Finding Problem Fix
P1: 7-day age-out The window was keyed on the chat's last activity. A chat untouched for 8 days lost its status and preview, and a chat cut 8+ days before relaunch was never settled. Removed for native chat entries; CLI rows keep it. An entry dies when its record is deleted, the host lets go of the chat (tab closed), the chat is unlisted at startup, or its next save replaces it.
P2-1: precomputed cut verdict A cut chat's saved status was mapped to a verdict before the per-chat recovery step had written its death proof. It could show "Couldn't confirm" and then flip to "Interrupted" a few seconds later. A cut chat is no longer shown from the save. Its startup open publishes the verdict its own settle wrote, so that verdict is the first and only one its row shows. Removed: the saved turn fence and its plumbing, the "new child under a turn" save trigger, and the read-time verdict mapping.
P2-2: re-save at startup, two dedupe maps The save-dedupe map started empty, so startup rewrote the whole file once per restored chat. The agent status store saves as it takes in each row, comparing against the entry it loaded. One map; restoring writes nothing.
P3-1: record-owned fields saved Model, provider session and launch directory were saved and could drift; a model switch on an idle chat stayed stale until the chat opened. Only the history's half is saved. On restore the record supplies the rest, through the same function a live row uses.
P3-2: duplicate mail scan The restored-mailbox queries existed twice. One shared restoredPendingMailboxHandles.
P3-3: unlisted mail-owed chat left open On a legacy profile without a tab index, a mail-owed chat that wasn't listed was opened and never closed. Mail-owed chats are opened only if listed.
P3-4: two filters The restore and the tab list chose chats differently, so a never-written chat got a status row with no tab. The restore uses the tab list's own check.
P3-5 and P3-6: newer builds A chat save could overwrite a newer-version file with hooks off, and a newer build's entries were dropped. Entries are written back verbatim until this build saves that chat. Unparseable entries, and entries for records this build can't read, are kept. A file this build can't read is never written over by a chat save with hooks off.
P3-7: dead method, long test file host.restoreReadableSessions had no production caller, and the startup test was over 300 lines. The method moved to the test-only collaboratorsForTests() (55 test call sites renamed). The startup test is 245 lines, with its setup in a fixture.
P3-8: PR body The "always agree" claim was overstated, the age-out was presented as a feature, and the Differences section was incomplete. Body updated. Visual Proof is pending live QA.

New tests (each fails without its fix)

  • A chat cut 9 days before relaunch is settled at startup, and every row it shows carries the settled verdict, never the saved one. This runs against a real agent status store over a real last-status.json.
  • A chat settled 9 days before relaunch lists with its saved status and the record's fields.
  • Restoring at startup leaves the file byte-for-byte unchanged, and nothing loaded is re-saved.
  • A newer build's entries are written back verbatim, and a file this build can't read isn't overwritten with hooks off.
  • Entries this build can't parse, and entries whose record a newer build wrote, are kept.
  • A chat with no written history gets no tab and no status row.

Ablations

27 rules, each broken in turn: all 27 went red. 10 cover these fixes and 17 are the original rules still in force. Two needed their test or patch corrected before they went red for the right reason:

  • "A saved status never overwrites a live one" stayed green at first. The live open re-saves before the restore reads, so the test now holds a stale saved read.
  • One patch first failed with an unrelated error, so it was redone to break only the verbatim write-back.

Verification

  • 366 test files that import the changed code: all pass.
  • Cross-version tests, one at a time: pass.
  • Boundary tests and reliability gates: pass.
  • Full oxlint, the changed-code gate, and the native-plugin, anti-slop and type-aware lints: clean.
  • Typecheck: clean for node, web and cli at 9673cd7. 19c2c43 only restores two files to their base content.

Re-applies saved native chat statuses onto main's agent status store: chat
saves and drops go through the throttled background writer (#26822), the
newer-build-file guard moves into the snapshot writer, the sink wiring moves
to agent-hook-runtime-deps, and permissionMode joins the record-owned summary
fields. host.restoreReadableSessions stays public as on main, so tests that
only renamed its call keep main's text; startup reaches the saved-status
restore through the host's restore collaborator.
The agent status store kept every saved native chat status raw so an entry a
newer build wrote went back to disk untouched. Users never downgrade Orca, so
each entry is now parsed on load and a malformed one is dropped; the
saved-status reader returns parsed statuses only. The cross-version test that
had the last release read this build's file goes with it.

The startup test's "Couldn't confirm" case now puts the owner on another
host: on main a local owner's runtime is replaced by the restart, which
proves the turn interrupted.
One whole-flow restart test: a previous run's agent status store saves the
chat's status, the restarted host shows a settled chat's Done, Failed or idle
status without opening its history, and settles a chat cut mid-turn to
Interrupted (proven death) or Couldn't confirm (owner on another host). The
parked-mail restart test and one store test (a chat's save with status hooks
off keeps the CLI agents' rows) stay. The unit tests of parsing, the restore
helper and the owed-mail scan go, and existing tests keep main's text apart
from the renamed restore call.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant