Repository navigation
fix(native-chat): list chat tabs at startup without opening every chat's history - #23978
brennanb2025 wants to merge 33 commits into
Conversation
…ailing app startup At startup the chat host re-checks every saved chat's lease and writes the result to agent-sessions.json. If that write failed (the file lock gave up, the file could not be written, or the file was written by a newer Orca and is read-only here), reconcileRestartLeases rejected, the startup IPC call rejected, and the renderer fell into its degraded "Session restore failed. Changes won't be saved until restart" mode. The reconcile is bookkeeping: a lease left unreconciled grants no writer, and every attach, send and read of a chat reconciles its own lease again. So the startup reconcile now reports its failure through a new optional host dependency, onStartupReconcileFailure, and resolves. The runtime routes it to its onError sink under the scope structured-agent-session-startup-reconcile, or logs it when no sink is installed (the desktop installs none).
…eping With native chat on and a chat tab open at quit, the renderer's startup also awaits the chat tab restore (session.tabs.listAll). That restore re-ran the lease reconcile before reading each chat and rethrew its store failure, then recorded each restored tab as visible through a store transaction that throws on a held lock or a read-only store. Either one failed the restore, so startup still fell into "Session restore failed". Reading a chat grants no writer, so the reconcile startup and the restore run is now a reader's: createReaderReconcile never throws, answers whether every lease is settled (recovery is resolved only then; the journal opens either way), and reports each distinct failure once until a reconcile settles. Attach and agent start keep the strict reconcile. The restore's tab republish logs a failed visibility write and still publishes the tab, since a client drops every unpublished chat tab; user-driven publishes still refuse. The host dependency is renamed onLeaseReconcileFailure (scope structured-agent-session-lease-reconcile), since it now also reports for reads.
… startup waiting on the lease check At startup the chat tab list, and the window's first paint behind it, waited for two things: the startup lease check over every saved chat, and opening and replaying the full history of every listed chat. The list itself was then taken from the chats that had opened, so a chat whose history was missing or could not be opened lost its tab after a restart, and one failed open or lease check failed the whole list. Now the tab list comes from the durable records and the tab table, in tab order, filtered to chats this host serves. It opens nothing. Each chat's history is opened after the list is published, by the same restore pass as before, not awaited; a failure there is logged and leaves the list alone. The startup lease check is started and never awaited: neither the list nor paint needs leases. Two things did rely on the startup wait: - a write to a chat whose lease is still the previous run's. Every write (send, Stop, prompt answer, option, goal, rewind, clear, compact, queued drafts, restart-offer resume) enters its chat's queue through the lease check now: it waits for that chat's check, or runs it, when the lease is unreconciled, and enqueues at once otherwise. Attach and agent start already ran the check themselves. - the structured worker release, which the renderer runs right after the startup step. It now runs the check itself before it observes the worker. No schema change, no saved status. Status rows now arrive per chat just after paint instead of all at paint.
…artup' into brennanb2025/chat-tabs-list-without-replay # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-host.ts # src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts # src/main/runtime/structured-agent-session-startup-reconcile.test.ts
…read path Round-2 review found two more writes on the startup chat restore that could still fail it and put the app into "Session restore failed": republishing a /clear replacement recorded its tab visibility strictly, and resolving a chat's recovery rethrew its store error. The restore also paid one lock wait per tab and per batch of chats while the lock stayed held. The restore now derives tabs from state it already holds: - publishStructuredAgentSessionTab splits into the strict write and projectStructuredAgentSessionTab, which only updates the runtime's snapshot. The restore and /clear replacements only project: a saved tab index already lists every restored chat, and a /clear moves the tab in the same write that commits it. visibilityWriteMayFail is gone. - Chats a legacy profile restores that the index does not list are recorded in one best-effort transaction (store.showSessionTabs), so a failure leaves the index absent to seed again rather than partial. - The read restore's recovery resolution is caught and reported through onLeaseReconcileFailure, deduplicated with the reconcile's reports. - Once lease bookkeeping fails in a restore pass, the rest of that pass skips it, so a held lock costs one wait for the startup reconcile and one for the restore, however many chats are open. User actions (create, reveal, attach, send, the /clear commit) keep their strict writes.
…op its bookkeeping at the first failure The restore now runs one reader lease check for the pass and lets each chat re-check and resolve recovery only while the pass is still settled. The first refusal or failed write clears it for the rest of the pass, and every chat is still opened for reading. With another process holding the lock, startup waits on it once in prepare and once in the restore, however many chats are open; a legacy profile waits once more for its tab-index seed.
…artup' into brennanb2025/chat-tabs-list-without-replay # Conflicts: # src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts # src/main/runtime/structured-agent-session-startup-reconcile.test.ts
…listed, one failure per chat The restore that reads each chat's history now runs after the tab list, while the app is already usable, so three things it did before the list existed now show: - A chat whose history failed to open ended one of the restore's four lanes. With four such chats, every chat after them got no status row until opened. Each chat now settles on its own and each failure is logged once. - A chat closed before the restore reached it was opened again and its status row republished. The restore now skips, under the chat's own serialized step, a chat whose tab is no longer listed. - After quit began, the restore kept opening chats. It now stops. Tests: the startup listing tests share the existing rest test rig, extended to several chats, instead of a parallel rig; the status parity test no longer leaves a temp directory behind. Comments that said an unreconciled lease admits no writer, or that a write waits for its own chat's check, now say what happens.
…as answered The restore that reads each chat's history was started, not awaited, from inside the listing call, so its synchronous start (about 110 ms on a 247-chat profile) ran on the listing's own stack and three chats opened before the list answered. It now starts on the next macrotask, after the answer has gone out. Also pins that listing writes nothing to the record store: tabs are projected from the store's own tab index, so a 247-tab list costs no per-tab transaction.
…artup' into brennanb2025/chat-tabs-list-without-replay
…e per-write gate This PR had stopped startup waiting for the lease check (the pass that settles each chat's previous-run owner) and instead made every write to an unchecked chat wait for it. After #23964 that check can no longer fail startup, and a review run measured it at about 30 ms on a 247-chat Mac profile, so the gate bought nothing but a second mechanism. It also reordered two writes that arrived while a chat's lease flipped from unchecked to checked. Startup's structured step, the write path and worker release are back to the base's shape: startup awaits the non-throwing check, writes enter the chat's queue in arrival order, and an agent start still runs its own strict check before it acts. What stays is the listing from records and the tab table, and the chat history restore that starts after the list has answered.
…answer is out The history restore was deferred with setImmediate from inside the tab restore, on the theory that the list answer would be sent by then. It is not: after the tab restore, session.tabs.listAll and the subscribe streams still run the PTY census (daemon, git and SSH calls), and the deferred restore fired during it. Four or five chats opened before the list answered, which is what the deferral was meant to prevent. The tab restore now only records the history restore it owes. Every session.tabs handler that restores tabs (list, listAll, subscribe, subscribeAll) goes through one wrapper that starts the owed restore when that handler's answer has settled: sent, failed or abandoned. The first to settle starts it; the rest find it started. The tab restore itself is no longer callable from a handler without that wrapper. Also: both quit checks in the restore now have a test (one skips the lease bookkeeping, one the open), and a comment no longer says a chat with missing history explains itself; it reads empty.
… timeout Both waited on the provider dispatch with vi.waitFor's default 1 s, and T3's wait failed once under load. Neighbouring structured-session tests wait 10 s for the same kind of assertion.
…t once per tab Every tab snapshot the runtime stores or answers re-applies the /clear replacements, and deriving them walks every chat record. The listing stores one snapshot per restored tab and answers one per worktree, so a 247-tab, 1,301-record profile derived them 289 times per listAll. On main those derivations were split up by the per-tab store writes this PR removed, so here they ran as one long main-process task (about 1.9 s on the Windows measurement machine). Each session.tabs listing request, tab restore included, now derives them at most once, on first use, and every snapshot it stores or answers reuses that value. Anything outside a listing, and any timer or task a listing leaves running after it answers, still derives them fresh. A /clear committed while a listing waits on I/O is still shown: its own commit re-projects the stored snapshot.
…g loop The previous commit kept one derived set of /clear replacements for a whole listing request, reused across the listing's later waits (the PTY census, the authoritative re-collection, subscribeAll's retries). A chat revealed from its history during one of those waits was then answered as its replacement: the stale set still renamed its tab. The request-wide scope is gone. The two loops that carry the cost each derive the set immediately before they run, with nothing awaited in between: the tab restore's projection loop (one snapshot per tab) and the collection's per-worktree answer loop, after its census. Both hand the set to storeMobileSessionSnapshot / toMobileSessionTabsResult through an optional parameter; every other caller derives fresh, as before. A first listing now derives twice (one per loop), later ones once, independent of tab and worktree count.
… a time The history restore that now runs after the tab list ran four chats at once. Its work is synchronous on the main thread (each open folds the chat's journal), so four lanes add no throughput: they only put up to four fold chunks in every event-loop turn, and each step of a user's read or send waits behind a whole turn. On the Mac measurement rig the first send after a restart waited 2.4-7.4 s behind the restore. It now opens one chat at a time. One chat's failure still costs only that chat, and a chat closed, or a host quitting, while it runs is still skipped.
Brings in main at 75040eb. main's record-store test harness moved the host test harness's store from <root>/store to <root>/agent-sessions, so the startup reconcile failure test now opens, edits and reads the relaunched store through agent-session-record-store-test-harness instead of the old path.
…up chat read The lease bookkeeping failure reporter called the host's failure sink directly, so a sink that threw turned a reported, recoverable store failure back into a rejected startup reconcile or read restore. The reporter now catches a sink throw and logs both the original failure and the sink error with console.warn.
…s agent once, after the check
Startup waits for the lease check (which settles each chat's owner from
the previous app run) only on the desktop window's path. A phone, the
CLI or a remote host's peer can send before it has finished, and the
check is not awaited on any RPC write.
This pins what keeps that safe. The send is accepted into the chat at
once, and its delivery's agent start runs the same check itself, so it
waits for the one already in flight. Two sends made while the check is
held start one agent, after the check, and both are delivered.
Without the agent start's own check, both messages are rejected
("couldn't restart", reason: the lease is still being checked) and no
agent starts.
…artup' into brennanb2025/chat-tabs-list-without-replay # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts
…s-list-without-replay # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts # src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts # src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts # src/main/runtime/structured-agent-session-startup-tab-restore.test.ts
The runtime module size test failed in CI: orca-runtime-runtime-id.ts had grown to 402 lines. The field for the owed chat-history restore moves to the module whose restoreStructuredAgentSessionTabs owns the tab restore, and an import this PR had reordered goes back to main's order.
…s-list-without-replay # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts # src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts # src/main/runtime/structured-agent-session-startup-tab-restore.test.ts
…s-list-without-replay
…er-listing test Main's host close now takes the cause, and a closed tab is the user's close.
…s-list-without-replay
… does The chat history restore now opens nothing once the host has begun quitting, so the two close-verdict tests that quit and then restore the chat on the same host could no longer open it. They now read the settled turn through a fresh host over the same store and journal, which is what the next launch sees.
…s-list-without-replay # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts # src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts
The pre-commit formatter ran over every file the merge staged and reformatted three of main's orcad files that this PR does not touch. They go back to main's exact content; the hook is skipped for this commit only, because it would rewrite them again.
Review status: staying a draft; lands together with the stacked saved-status changeThe problem. With native chat on, startup opened and replayed the full history of every saved chat before it would list any chat tab, and the window's first paint waited for that. With 247 chats the tab list took 6–10 s on main. One chat with damaged history made the whole tab list fail ("Session restore failed", and no chat tabs for phones or remote clients). A chat with no history on disk lost its tab. What changes for the user. Chat tabs come straight from what is saved on disk (7–22 ms instead of 6–10 s), and the window paints 2.5–12 s sooner. A damaged chat keeps its tab and shows "Unable to load this chat.". Each chat's sidebar status row now fills in after paint, from a background pass that reads one chat's history at a time. Why it stays a draft. On high-spec Windows every measurement target passes: all status rows arrive +0.2 s vs main, finishing 3.2 s after paint. On the Mac the status rows miss their target: they finish 4.6–6.6 s after paint (target 2 s). On the first launch after upgrading from the old per-chat history files, the last row arrives 6.6 s later than on main. A message sent the instant the tabs appear is accepted 1.2–2.4 s later than on main, though still earlier than main when measured from launch. The planned fix is to save each chat's status with its history, so startup settles only chats whose saved status says a turn was running. That removes the background pass. It is #24233, stacked on this branch. A joint measurement gate (that PR's head, which includes this one, vs main on the Mac and high-spec Windows) decides when both are marked ready together. Fixed during review.
Verified.
Not verified.
Deferred. All are pre-existing or separate:
|
Conflicts: - The session host's tab methods now live in main's tab surface module; its tab listing reads the record store for the given tab ids, as this branch's host did, instead of the open sessions. - The tab RPCs keep this branch's answer-then-start-history-restore wrapper and take main's capability-only gate (the host chat setting no longer gates projection or restore).
|
Merged current How the conflicts were resolved:
Checked after the merge: node and web typechecks pass; this branch's changed tests and every test that imports the conflicted files pass (105 files); the five cross-version compatibility tests pass. The PR stays a draft. |
…it after the main merge The merge brought main's new idle-evidence lines into a module this branch had grown for its snapshot replacements parameter, which took it past the runtime module size limit. The parameter's type is now inferred from its default and its note joins the method's doc line.
|
Closing: its startup tab listing (list chat tabs without opening every chat's history) is now part of #26439, which also keeps each chat's status across a restart. |
Draft: lands together with the stacked saved-status PR, which removes the post-paint history pass (the Mac status-row fill fails this PR's gate on its own).
ELI5
When Orca starts, it opened and replayed the full history of every saved chat before it would show the chat tabs, and the window waited for that. With 247 saved chats on a fast Windows machine, the tab list took about 6 s and the window finished loading 6 s later than it needed to. Worse, if one chat's history file was damaged, the whole chat tab list failed: phones and remote clients got no chat tabs, and the desktop showed "Session restore failed". This PR builds the tab list from what is already saved, shows the window, and then reads each chat's history in the background.
What it fixes: slow startup with many saved chats, the whole chat tab list failing when one chat's history is damaged, and chat tabs silently disappearing when their history is missing.
Scope: structured (native) chat tabs at app startup: the desktop window, and any client that asks an Orca host for its tab list (mobile, a remote/SSH host). Local, SSH and folder workspaces behave the same.
Not affected: terminal tabs, the chat database format (no schema change), the network protocol between clients and hosts (no field or message changes), how a send reaches the agent, and the startup check of which Orca process owns each chat (unchanged from main).
Plan: step 1 was #23964 (merged): a failed startup save of chat ownership no longer puts the app into "Session restore failed". This PR is step 2: the tab list and first paint stop waiting on every chat's history. Step 3, stacked on this PR and landing with it: each chat's status is saved with its history, so startup no longer reads every chat's history after paint. Other follow-ups are listed at the end. Supersedes #23131.
What Changed
The problem, as the user saw it
Before and after, for the user
The mechanism
/compactand orchestration mail) is saved in the chat as pending; the agent start that delivers it runs the lease check itself, waits for one already running, and only then starts the agent;/clearand goal changes start the agent first, through the same check;structured-agent-session-send-during-startup-lease-check.test.tsholds the startup check open, sends twice, and shows no agent starts until the check finishes, then exactly one start and both messages delivered.Why
Differences from the common pattern
Linked Issue
N/A (maintainer change, no issue). Supersedes #23131.
Visual Proof
Before (main), Mac, a profile with one damaged chat history: the tab list request fails, the app shows "Session restore failed", and the active chat (the damaged one) is blank. The tabs in the strip are the window's own saved layout; the host listed none. The "Resume interrupted chats?" dialog belongs to the test profile and appears on both builds.
After (this PR), same profile: every chat is listed, including the damaged one, which shows the existing "Unable to load this chat." The other chats and their status rows are unaffected.
After (this PR), M4 MacBook Air, 26 chats, the window's first paint (cropped to the sidebar): projects and workspaces are shown, and the chat status rows have not arrived yet. The grey dots come from the existing 30-minute status-freshness rule on the test rig's seeded chats, not from this PR.
About a second later: every workspace's chat status row has filled in (measured 150–224 ms after paint on this machine). The grey dots are the same existing freshness rule as above.
A message sent after startup is accepted and answered in 0.55 s (the test rig's stand-in agent echoes it). On this 26-chat machine the status rows were already complete, so this does not show a send made during the background reading; the measurement tables below do.
Measurements
Measured at 316d7cc against main at its merge base (7980ab9). The PR's production code has not changed in behaviour since: later commits add one test, adapt the test rig to main's store harness, move one field between runtime files to keep a file under its size limit, and merge #23964 and main. Each row is the median of 3 interleaved main/PR pairs, 247 saved chats (1,301 records). A1 = first launch after upgrading from per-chat history files; A2 = second launch.
Mac (a laptop shared with other work, 19–49% busy before each launch), same seed and method: A1 is 3 pairs; A2 is the 2 clean pairs (a third main run stalled for 36 s in the rig and is excluded). Mac fails three targets, marked below.
The Mac's row-fill numbers are the case this PR does not fix. On the first launch after upgrading, the background reading folds each chat's old per-chat history file one chat at a time on the main process. Main's startup did the same work before the window loaded, four chats at a time. That is why A1's rows finish later than main's. One unjudged, heavily loaded PR run took 4.0 s for the first send.
Low-spec Windows was not measured: its disk was full (0.24 GB free), so the measurement build could not be installed.
Testing
New or changed tests, each shown red without the piece it covers (the piece removed or reverted, the test run, then restored):
structured-agent-session-tab-listing-startup.test.ts(real runtime, host and store): the list answers before any chat opens, then each opens (red when the restore starts before the answer); for bothlistAllandsubscribeAllthe restore starts only after the answer, census included (red when it started at the end of the tab restore); /clear replacements are derived once per loop (red at the earlier per-tab derivation: 13 calls); a chat revealed while the listing waits on its census is answered correctly (red on the intermediate request-wide cache); the listing writes nothing to the record store (red with a store write per tab); four failing chats leave every tab listed and the rest with status rows (red with the old lane pool that stopped on failures); tab-table order and saved window order.structured-agent-session-persisted-tab-listing.test.ts: a chat with no history keeps its tab, reads empty and takes a send; a chat whose history cannot be opened keeps its tab and its read refuses withagent_session_journal_unreadable; ids listed once in the given order.structured-agent-session-restore-after-listing.test.ts: a chat closed before the restore reaches it stays closed (red without the still-listed check); nothing opens and no ownership bookkeeping runs once the host is quitting (red without the quit checks).structured-agent-session-restart-restore.test.ts: the restore opens one chat at a time (red with four lanes).structured-agent-session-send-during-startup-lease-check.test.ts: two sends during a held startup ownership check are accepted at once, start no agent until the check finishes, then exactly one agent starts and both are delivered. Removing the agent start's own check turns it red: both messages are rejected ("couldn't restart", still checking) and no agent starts.structured-agent-session-restored-status-parity.test.ts(regression guard, passes on main too): the status each chat ends with after the restore equals a fresh direct read (clean quit, created-only, crashed mid-turn, pending send), and the crashed chat never shows its pre-crash "working" row.Runs at the current head (after merging main): every PR test file, plus the three
tests/e2eunit tests that stub the chat host (structured-session-reopened-tab-close,structured-chat-owner-status-activation,cross-version-agent-session-wire): 16 files, 137/137 pass.pnpm tc:nodepasses. oxlint and oxfmt clean on the changed files. The earlier body's claims about a write gate, its test and "typecheck not run" are gone: none of those apply to this head.Manual: high-spec Windows measurement runs above (30 runs, each checked for 247 tabs and 247 status rows, isolated home and settings); M4 MacBook Air QA with 26 chats including damaged, missing and never-sent histories (all tabs at paint, rows 150–224 ms after paint, a send answered in 0.55 s); Mac measurement runs above (12 timed runs at 316d7cc, 10 judged, plus an instrumented correctness launch: damaged chat shows "Unable to load this chat.", folder-workspace chat lists and renders, crashed chat never shows "working"; screenshots taken over the debugging protocol, no OS input).
AI Disclosure
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
Known and deferred (not introduced here):
Follow-ups (the rest of the plan):
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)