Skip to content

fix(native-chat): list chat tabs at startup without opening every chat's history - #23978

Closed
brennanb2025 wants to merge 33 commits into
mainfrom
brennanb2025/chat-tabs-list-without-replay
Closed

brennanb2025 wants to merge 33 commits into
mainfrom
brennanb2025/chat-tabs-list-without-replay

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 14 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​878 $\color{#cf222e}{\Huge{\mathbf{−}}}$​22 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​856
Prod 15 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​366 $\color{#cf222e}{\Huge{\mathbf{−}}}$​143 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​223

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

  • Startup waited to open and replay every listed chat's history before it could list a single chat tab, and the window's loading step waited for the list.
  • On high-spec Windows with 247 saved chats, the tab list answered in 6.0–6.2 s; on this PR it answers in 7–28 ms (measurements below).
  • Because the list was built from the chats that had opened, a chat whose history could not be opened (damaged, locked, written by a newer Orca) failed the whole list: the host answered no chat tabs at all (so phones and remote clients showed none), and the desktop showed "Session restore failed" with "Changes won't be saved until restart" (screenshot below).
  • A chat whose history file was missing entirely silently lost its tab after a restart.

Before and after, for the user

  • Before: the window waited for every chat's history; every sidebar status row was there when it appeared.
  • After: the window finishes loading sooner: 6 s sooner on high-spec Windows and 2.5–12 s sooner on Mac (247 chats). Sidebar status rows are no longer there at that moment; they fill in chat by chat afterwards, all within about 3.2 s on high-spec Windows but 4.6–6.6 s on Mac.
  • After: a chat whose history is damaged keeps its tab; opening it shows the existing "Unable to load this chat." message. The other chats are unaffected.
  • After: a chat whose history file is missing keeps its tab, but it opens empty, with no notice that its history was lost. Before, the tab disappeared. Adding a notice for this case is a product decision left out of this PR.
  • Worse on Mac, first launch after upgrading: all status rows arrive 6.6 s later than on main, measured from launch (second launch: 3.2 s earlier). The Mac misses its row-fill target (below). This is the reason for the stored-status follow-up.
  • Trade-off: a message sent the instant the tabs appear is accepted later than on main: 1.15–1.79 s vs 0.14–0.15 s on high-spec Windows, and 1.2–2.4 s vs 0.3–0.5 s on Mac. The background history reading is still busy on the main process. Measured from launch the message is still accepted earlier than on main (about 5.6 s earlier on Windows; 1.9–7.8 s earlier on Mac), because the tabs are up sooner. Once the background reading is done, a send is as fast as main's (measured on Windows).

The mechanism

  • Tab list from saved state. The list is built from the saved chat records and the saved tab table, in tab order (or the saved window order on a profile with no tab table), keeping only chats this host can serve. It opens no chat history and writes nothing.
  • History after the answer. The existing read-only history restore now starts only after the tab list's answer has been sent, one chat at a time, so it never delays the answer and leaves the most room for the user's own reads and sends. A chat that fails costs only that chat and is logged once; a chat closed, or a host quitting, before the restore reaches it is skipped.
  • Ownership check unchanged. At startup, Orca checks every chat's "lease" (the saved record of which Orca process owns that chat's agent, so two processes never run the same chat). That check is still awaited in startup's preparation step, exactly as on main; this PR does not move it.
  • Every write stays safe on its own. A phone, the CLI or a remote client can write before any tab list has been requested, so safety cannot depend on startup order, and it does not:
    • a send (including a restart-offer accept, /compact and 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;
    • rewind, /clear and goal changes start the agent first, through the same check;
    • prompt answers and background-task cancels need a checked lease and are refused ("still checking") until then;
    • Stop and queued-draft edits only change the chat's own queue, whoever owns the agent.
    • The attach step runs the check again, and the ownership swap refuses a lease that is still unchecked, so no path can start a second agent or deliver to a previous run's agent.
    • The new test structured-agent-session-send-during-startup-lease-check.test.ts holds the startup check open, sends twice, and shows no agent starts until the check finishes, then exactly one start and both messages delivered.
  • Listing is keyed by chat id and workspace id, never by path, so folder workspaces and SSH hosts behave identically.

Why

  • Neither the tab list nor paint needs chat histories; waiting for them was bookkeeping gating what the user sees.
  • Listing from saved state matches the common pattern: chats are listed from stored rows, never by replaying their history.
  • Why not perf(native-chat): chat tabs list from saved state, and history opens on first use #23131's saved-status store: it kept a second persisted copy of each chat's status with its own trust rules. The tab-list and paint win comes from not waiting on history, which this PR gets without new stored state. The proper version of stored status is the follow-up below, written in the same transaction as the history itself.
  • Why the ownership check stays awaited: an earlier version of this PR stopped waiting for it, but the status rows gained nothing (the history reading waits for the check anyway), and it needed an extra wait on every write that could reorder two writes to one chat. Keeping main's order removed both, and every write is already safe on its own (above). The check's 1–2 s cost is pre-existing and has its own follow-up.

Differences from the common pattern

  • Status rows come from reading each chat's history after paint. Temporary. The common pattern keeps each chat's lifecycle status (idle, running, which turn) on the chat's own row and, at startup, settles interrupted turns from that stored status without replaying history. Orca has no such stored status yet, so it reads every chat's history after paint to rebuild status and settle a turn a crash cut short. Follow-up: store each chat's lifecycle status on its session row in the same transaction as the journal append, and at startup settle only the chats whose stored status says a turn was running. That removes this pass, the 1–2 s first-send delay and the 3–7 s row fill. The Mac failing its row-fill target in this PR's measurement is the stated trigger for that follow-up.
  • A write made during startup is accepted, and checked when it is delivered. Intended. Startup does not hold writes until its ownership check finishes; a message is accepted into the chat, and its ownership is checked when it is dispatched to the agent. Orca already worked this way and keeps it: the check sits where work reaches the agent, so it covers every client, including ones that never ask for a tab list.

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.

Main: "Session restore failed" and a blank chat after the tab list request fails on a damaged chat history

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.

PR: all chat tabs listed; the damaged chat shows "Unable to load this chat."

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.

PR: first paint, before the chat status rows arrive

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.

PR: chat status rows filled in shortly after first paint

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.

PR: a message sent after startup, answered

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.

Row What Target Windows high-spec A2 Windows high-spec A1
P1 Tab list answer time ≤ 0.5 s main 6.00–6.21 s → PR 7 ms main 6.04–6.11 s → PR 28 ms
P2 Window finished loading (from launch) Δ ≤ −2.0 s (A1: no worse) −6.01 s (−6.86, −6.01, −5.88) −6.07 s (−5.95, −6.37, −6.07)
P3 All status rows in (from launch) Δ ≤ +1.0 s +0.20 s (−0.54, +0.20, +0.48) +0.44 s (+0.61, +0.12, +0.44)
P3 All rows in, after the window finished loading ≤ 5 s 3.16–3.23 s 3.17–3.29 s
P4 Longest main-process block Δ ≤ +10% −0% (+12, −0, −6%) −3% (+16, −9, −3%)
P4 Blocks over 150 ms caused by the listing none 0 of 12 runs, both builds 0 of 12 runs, both builds
C1 History opens before the tab list answers 0 0 (main: 246, by design) 0
C3 Never-sent chat and damaged chat listed after restart listed PR lists 249 of 249; main lists 0 (its tab list fails) —
C4 Old per-chat history files copied at startup same as main same as main (446 of 447 left in place) same
— First send made the instant the tabs appear: tabs up → accepted reported main 0.14–0.15 s → PR 1.15–1.79 s (+1.0 s) —
— Same send, launch → accepted reported PR 5.6 s earlier (−5.43, −5.62, −11.07) —
— Send after the history reading is done reported main 201–440 ms → PR 165–167 ms —
— Memory after all rows reported equal (RSS 819–862 vs 823–860 MB) equal

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.

Row What Mac target Mac A2 Mac A1
P1 Tab list answer time ≤ 25% of main main 8.2 / 12.6 s → PR 22 ms (0.2%) main 5.3–7.0 s → PR 20 ms (0.3%)
P2 Window finished loading (from launch) no worse than main −12.3 s (−9.23, −15.36) −2.45 s (−5.27, −2.45, −2.18)
— Tab strip first shown (not a target) reported −2.97 s +1.6 s (later on the PR in all 3 pairs)
P3 All status rows in (from launch) Δ ≤ +1.0 s −3.16 s (−1.82, −4.49) FAIL: +6.62 s (+2.59, +6.62, +7.90)
P3 All rows in, after the window finished loading ≤ 2 s FAIL: 4.56 s (5.07, 4.05) FAIL: 6.57 s (5.27, 6.57, 6.74)
P4 Longest main-process block Δ ≤ +10% −64 ms (98 vs 162 ms) FAIL as measured: +26% (207 vs 118 ms). The extra blocks are startup work before the app is ready, identical code on both builds; the listing itself had 0 ms blocks. One 184 ms block came from the background history reading.
C1 History opens before the tab list answers 0 0 (instrumented runs) 0
C2 Chat killed mid-turn never shows its old "working" row must hold holds: the row goes from absent to done, never working —
C3 Damaged, missing-history, never-sent, crashed and folder-workspace chats listed listed PR lists all 247; main lists 0 on the same profile —
C4 Old per-chat history files copied at startup same as main same as main same as main
— First send the instant the tabs appear: tabs up → accepted reported main 494 ms → PR 1.21–1.89 s main 274–337 ms → PR 1.38–2.40 s
— Same send, launch → accepted reported PR 7.8 s earlier (1 pair) PR 1.9 s earlier (median of 2 pairs)
— Memory after all rows reported RSS −85 MB, heap −54 MB RSS −56 MB, heap −55 MB

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

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

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 both listAll and subscribeAll the 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 with agent_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.
  • Updated: listing callers pass tab ids; the startup restore order test now expects history last.

Runs at the current head (after merging main): every PR test file, plus the three tests/e2e unit 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:node passes. 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

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • SSH and remote hosts run the same chat host, so their tab list gets the same change. No liveness verdict changes: a chat whose ownership cannot be checked stays "unverifiable", never "exited".
  • Mixed client and host versions: no request field, message or stream type changes. A host's tab list may now include a chat whose history does not open, and status rows arrive after the list; older clients already handle both.
  • Downgrade: no schema change; an older build lists as it always did.

Known and deferred (not introduced here):

  • Background reading order: with one chat at a time, a chat whose agent is being started by the user holds up the status rows after it until that start finishes. Timing only; it ends with the start.
  • Ownership check cost: the startup ownership check takes 0.9–1.2 s on high-spec Windows and 1.7–2.2 s on Mac with 1,301 records, on main and here alike. A separate small PR makes it skip the process lookup for chats with no running owner.
  • Old per-chat history files re-read every launch: about half the background reading time is re-reading history files from before the single chat database, on every launch, on main and here alike. A separate follow-up stops that.
  • Option pick before the ownership check: changing a model or option on a chat with no running agent saves the choice before that chat's ownership is checked. Worst case the pick is refused and has to be repeated; no agent is started. Pre-existing on main.
  • Worker release reading an unchecked chat: an orchestration worker release that runs before the ownership check, on a host with no desktop window, can read an unchecked chat as running and label its release wrongly. There is no process to stop, so nothing is signalled. Pre-existing on main.

Follow-ups (the rest of the plan):

  • Store each chat's lifecycle status on its session row in the same transaction as the history append, and settle only chats whose stored status says a turn was running. Removes the background history reading, the ~1 s first-send delay and the ~3 s row fill.
  • refactor(native-chat): keep agent-session records in the chat journal database #24006 keeps chat records in the chat history database.
  • Ownership check cost (above) and stopping the per-launch re-read of old history files.

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)

…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
@brennanb2025
brennanb2025 changed the base branch from brennanb2025/records-readonly-startup to main September 30, 2026 18:49
…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
@brennanb2025 brennanb2025 changed the title fix(native-chat): list chat tabs without opening every chat, and stop startup waiting on the lease check fix(native-chat): list chat tabs at startup without opening every chat's history Sep 30, 2026
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
…er-listing test

Main's host close now takes the cause, and a closed tab is the user's
close.
… 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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: staying a draft; lands together with the stacked saved-status change

The 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.

  • The per-write wait on the startup ownership check (the "lease" check) is removed. It could reorder two writes to the same chat. The check is awaited at startup again, as on main.
  • The background pass no longer starts before the tab list's answer is sent.
  • It keeps going when one chat fails to open, stops when the app quits, and skips chats closed while it runs.
  • It now reads one chat at a time, which cut the first-send delay by about two thirds.
  • Listing derives the /clear replacements right before each loop, so a chat revealed mid-listing is not renamed wrongly.
  • Two flaky test timeouts were fixed. The test rig reuses the existing rig instead of a copy.
  • CI: a runtime file went over its 400-line limit, fixed by moving one field to the file that owns it. Two of main's new quit tests restored history on a quitting app, which this PR deliberately refuses; they now read through a fresh app over the same data, which is what the next launch sees.

Verified.

  • Every write entry point (desktop, phone or paired client, CLI and RPC) was traced. Each write that can start an agent runs its own ownership check first. A new test holds a slow startup check and sends; the send starts exactly one agent, after the check. With that per-send check removed, the test fails.
  • Readiness checklist on the final diff: no P0–P2 findings.
  • At head 5735ee9 (merged with main 0d2300c): 155 of 155 targeted tests pass, the node typecheck passes, lint is clean, and CI is green (17 pass, 16 skipped).
  • Measurement gate: 12 paired runs vs main on high-spec Windows and 12 on the Mac, plus a damaged-profile correctness launch on each.
  • Live QA on a second Mac: all 26 tabs are listed at paint, including a damaged chat, a chat with missing history and a never-sent chat. Status rows fill with no reordering. /clear and /compact survive a restart. An early send is answered.

Not verified.

  • Low-spec Windows was not measured; its disk was full.
  • The QA send happened after the status rows had finished (26-chat profile), so a send made during the background pass is covered only by the measurements.
  • Main has moved again since 5735ee9. Main is merged in once more only when the joint gate has passed and the PRs are ready to land.

Deferred. All are pre-existing or separate:

  • Picking a model or option before the ownership check saves the pick to the chat's record.
  • A worker release can read an unchecked chat as running.
  • The startup ownership check costs 0.9–2.2 s on every launch (on main too); a separate PR is planned.
  • The old per-chat history files are re-read on every launch.
  • A chat with missing history opens empty with no notice (product call).
  • A red "Conversation operation was not confirmed." line appears about 1 s after /compact. It is identical on main, so not this PR.

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).
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Merged current main (56427f1) into this branch as 8f2d61a. This PR conflicted with main, so CI could not run, and the session that owns it is not running. The stacked follow-up (#24233) needs this branch on current main to be measured and tested.

How the conflicts were resolved:

  • The session host's chat-tab methods now live in main's separate tab module. Its tab listing still reads saved chat records for the requested tabs, as this branch did, instead of the chats already open.
  • The tab-listing calls keep this branch's "answer the listing first, then start the chat history restore" wrapper. They follow main's rule that a client's advertised capability decides whether it sees chats, no longer the host's chat setting.

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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

This PR's changes are carried by #24233, which now targets main and contains this branch's head (00cdc2cff41). The two must land together, so merge #24233 only. This PR will then be closed as included. Please don't merge this one on its own.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

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.

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