Skip to content

feat(native-chat): copy old per-chat files into the chat database in the background - #24462

Closed
brennanb2025 wants to merge 52 commits into
brennanb2025/chat-status-stored-with-historyfrom
brennanb2025/old-chats-copied-in-background
Closed

brennanb2025 wants to merge 52 commits into
brennanb2025/chat-status-stored-with-historyfrom
brennanb2025/old-chats-copied-in-background

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Paused: to be superseded by the copy-on-first-read follow-up to #24127; this PR's measured pieces carry into it.

Files Added Deleted Net
Test 17 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3549 $\color{#cf222e}{\Huge{\mathbf{−}}}$​21 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3528
Prod 53 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2668 $\color{#cf222e}{\Huge{\mathbf{−}}}$​377 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2291

This PR is step L′-c of: #23978 → #24233 → L′-c (this); the three land together. #23978 lists chat tabs at startup without opening every chat's history. #24233 stores each chat's status beside its history, so startup can show statuses at paint and settle crashed chats without opening them. This PR makes #24233's stored status exist for every chat on an upgraded profile.

ELI5

Older Orca kept each chat's history in its own small file. Newer Orca keeps all chats in one shared database, but it only moved a chat over when you used that chat. So on almost every upgraded profile, nearly every chat is still in its old file, and startup has to read those old files again on every launch before it can show their status. This PR moves the old files into the shared database quietly in the background, a few chats at a time, after the first launch. From the next launch on, every chat's status is there at paint.

What Changed

The problem. #23613 moved chat history into one shared database (agent-session-journal.db), but copies a chat out of its old per-chat file (agent-session-journal/<workspace>/<session>/journal.db) only on that chat's first use. #24233 stores each chat's status beside its history, and startup seeds statuses from those stored rows. A chat still in its old file has no row, so #24233 cannot seed it: it is re-read from its old file after the tab list answers, on every launch, until someone uses it. On real upgraded profiles that is almost every chat (247 of 247 on the test seeds). With #23978 + #24233 alone, statuses on the Mac appear 4.6–6.6 s after paint, which is worse than main (statuses before paint). On the low-spec Windows machine, re-reading old files costs 13.4 s of CPU per launch inside the listing window.

A second gap, from #24233's own migration. #24233's version 5 migration creates the status table empty. A chat already in the shared database then has no status row until it is opened, and startup selects the chats to settle only from rows. So after upgrading, a chat whose tab you closed and that a crash left mid-turn is never settled at startup until someone opens it.

What you see after this change.

  • First launch after upgrading: looks the same as with feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233 alone. The copy runs in the background no sooner than 10 s after startup, once startup restoration has settled (the step the first tab list waits for, which waits up to the first window's services or their 12 s timeout) and the first tab listing has answered, so never before the first paint, and only while no tab listing, history restore or crash settle is in flight; nothing is visible while it runs. A host no client lists (an orcad no client attaches to, a headless host) starts it 60 s after startup instead.
  • Every later launch: every chat's status is in the sidebar, orca worktree ps, mobile and the dashboard at paint, as main shows today. No old file is read at startup.
  • A chat an older Orca or a crash left mid-turn is settled by the copy itself, so later launches have no extra startup work for it.
  • A send to the chat being copied waits for the rest of that chat's copy, which is never longer than its first use costs today: the copy's pace never makes it wait. Any other chat waits at most one task of the copy.
  • The copy never runs while a chat is streaming or a send is in flight. It holds while a send is unanswered (handed to its agent, or waiting while it starts the agent), and for 5 s after any chat's last frame from its agent (streamed text, reasoning, tool output, prompts); a chat whose copy is under way when a frame arrives stops at its next batch, publishes nothing and is copied later. A turn that has gone quiet holds nothing: one parked on an approval or a question, or running a long tool call with no output, lets the copy go on, because nothing then runs on the main thread for it, and its next frame stops the copy again. The one exception is the short step between chats (the folder walk, the disk probe, reading which chats lack a status row): a frame that arrives in the middle of it waits for that one step, and the next chat does not start.
  • If chats never go quiet (an agent streaming all the time), the copy does not run at all: it waits for a 5 s gap or the next launch. Nothing is lost or stuck meanwhile: every chat not yet copied opens with its whole history the way it does today (a restored chat from its old file, any other through its first use's own copy, neither paced), and what is owed is read from the files on disk, so the next gap or launch picks it up. The cost of that wait is the per-launch read of old files this PR removes, for as long as it lasts.
  • Quitting during the copy stops it within one batch.
  • Disk use stays about flat (copy one chat, then delete its old file). Below 512 MiB free the copy waits; a chat too big for the space that is free is left for a later launch while smaller chats still copy.

The mechanism.

  • A new host-owned background job (structured-agent-session-per-chat-file-copy.ts), started once per host from the startup step after the settle is kicked off. Desktop, orcad and a headless host all run it.
  • A fixed interval (1 s) starts a run when none is going and the gate is open; a run goes on, chat after chat, until the gate closes, the disk guard stops it or nothing is left. One pace alone sets its rate: a token bucket holding the copy to 15% of the main thread's wall time with a 50 ms burst, through which every task the job runs ends (each copy, delete and verify batch, each missing-row fold batch, each step between chats). The pace waits only between chats, where the copy holds no chat's lock: inside a chat every task only yields to the next macrotask, and its time is kept as debt for the next step between chats. So anyone who opens, reads or commands that chat, through the host's lock or a restored chat's own write queue, waits for the rest of that chat's copy and never for the pace. Waits that are not the copy's work (a chat's lock, the disk probe) are not charged. A chat the startup restore already opened from its old file is copied through its own owed import, whose wall time is charged to the pace. Each verify and fold batch is bounded by size, at most 256 Ki characters of row JSON (a larger row goes alone); each page of a per-chat file is read in one task and written in the next, and for the job's own work (its imports, their verify, and a restored chat's owed import when the job pays it) its size follows each task's wall time toward 25 ms: a task over it halves the next page, and two tasks in a row under half of it grow it by a quarter, within 8 and 512 rows. A user's own first open, and an owed import a user's read or send pays, take whole pages, as before. The size is measured within each chat's copy, in memory only: every chat starts from the whole page, so a fast machine never stays on small pages after a slow stretch. So a fast machine commits whole 512-row pages (every commit also rewrites a fixed set of pages, so smaller commits would mean more WAL checkpoints for the same rows) and a slow disk or CPU gets smaller ones; a page holding more than 2 Mi characters still splits. The leftover delete and both verify sides read the same page. The missing-row fold is feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's and keeps its fixed page (512 rows, split at 256 Ki characters). The copy's last batch, the verify's two sides and the publish are separate tasks, so no chat runs in one task. Quit ends a pace wait at once.
  • An activity gate re-derived before every run and every chat: no run in the first 10 s, none before startup restoration's first try has settled (every host runs it, and the first tab listing waits for it), none before the first tab listing has answered (or 60 s from startup on a host no client lists: one bound so such a host still copies), and none while a tab listing, a history restore (owed, starting or running), feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's settle step or its startup status pass (the fold of the listed chats with no status row, after paint) is in flight. The job waits for the settle; feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's command gate never waits for the job.
  • No copy while a chat works. The signal is the agent's own frames, at the one place each provider hands them to the host (each adapter's delivery of a frame, through the chat's host event sink): every frame, streamed deltas included. A row write would not do: streamed text, reasoning and tool output are first gathered for 60 ms and then written only as the text grows by an eighth, so a long answer can go many seconds between rows while the main thread parses every delta. Nor would the shared delta gatherer plus row writes: Claude's thinking and tool-input deltas pass through neither (only text deltas are gathered, and those frames write no row), so a long extended-thinking stretch or a large streamed tool input would read as quiet. The change to the Claude adapter is one call in its frame delivery, with no change in behaviour. The job's gate holds while an open chat has a send its agent has not answered (the existing unanswered-send rule, read again only when the chat's journal tip, fence or agent readiness moved, so a gate check costs microseconds rather than a walk of every send) or a frame arrived within the last 5 s. A send not yet handed over counts while its agent starts, so a send that starts a chat's agent holds the copy through the spawn, the history drain and the hand-over; only a send waiting behind a ready agent's running turn or prompt does not count, so a parked turn with a queued message holds nothing. Rows a provider left before its sink was bound also count as activity when the bind writes them. Each frame also stops the chat being copied, through a per-chat signal composed with quit, which the importer, its verify, a restored chat's owed import (passed through the chat's write queue) and feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's missing-row fold all check before each batch. They also check it before opening the old file, before deriving the status row ahead of the publish, and before settling a chat they just copied or gave its row (startup settles that row instead); the gate is checked again when a chat's signal is taken, after the walk and the disk probe. The stop publishes nothing, is not a failure (nothing recorded, no retry counted), and puts the chat first for the next quiet period; the rows it staged are deleted by the next try's sliced delete. Nothing waits inside a chat while holding its lock. A chat larger than any gap the agents leave is tried again in each gap, each try stopping within one batch of a frame, and stays in its old file (read as today) until a gap covers it; the next launch derives it again. An open chat is never copied by the job: its own open copies it.
  • Order: listed chats first, in tab order, then a walk of the old-file folder. A file is matched to its chat through the chat records; a file no record names, and a -recovered-v3 folder an older build wrote, is never opened or deleted. What is owed is derived from the files on disk and the stored rows; no progress cursor is stored.
  • Each chat is copied inside that chat's host serialize, through the existing importer. The importer gains: an abort checked before every copy batch, every verify batch on both sides, and the publish; the rows a stopped try left behind deleted 512 per task before the copy starts, not in one statement; a verify whose file side only hashes and whose copy side folds the rows with the replay's own fold; the chat's status row written in the publish transaction from that fold (no second replay); the load returned to the caller; deletion of a never-written file; and the file's size and modification time taken before the verify reads it and checked again after the publish, so a file written during or after the verify is kept and later set aside, not deleted.
  • A chat restored from its old file at startup (feat(native-chat): one structured-chat journal database per host, owned by one process #23613's read-only preview) is copied through its own owed import, which now adopts the copy's fold instead of replaying the chat.
  • A copied chat whose new row shows work a gone process left is settled at once, through an open that uses the copy's fold in place of a replay, then closed again. It gets no status row on the sidebar. Both of the job's settles (a copied chat, a chat given its missing row) go through feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's one closed-chat settle, which takes the copy's fold in place of a replay and refuses a chat this host cannot settle (its record is gone, or a remote or WSL record, or on Windows a local chat whose process start times cannot be read). A settle that fails is logged as a settle, not as a failed copy, and the next startup settles the row.
  • After the old files, the same job, under the same pace and gate, writes the status row every chat already in the shared database is missing: it folds the chat 512 rows per task, then derives and writes the row in one short transaction, only if the chat is still where the fold read it. A chat whose row shows work left is settled the same way. A chat this host cannot settle (by the same rule) gets no row and never starts or keeps the job alive: startup drops such a row at boot, so writing it would loop every launch. A chat whose history is corrupt gets no row either, as feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's startup pass leaves one, so its open rebuilds it; a copied one is published without a row, and the missing-row phase gives one up until its rows change (its rebuild changes them), so no launch re-folds it or starts the job for it.
  • A step that fails in a way no retry reads past is recorded in a new journal_background_failures table, keyed to that step's input and the app version: a copy (the file won't open, or the copy didn't read back as the file) to the file's size and modification time and its WAL's; a missing row that could not be written to the chat's epoch and last sequence. The job skips it while all of that holds, and the next launch does not start the job for it; any change makes it owed again for one more try. A transient failure (disk full, busy, I/O) is not recorded: a copy gets at most 3 tries per launch, a missing row one.
  • A disk guard: a chat starts only with at least max(512 MiB, 4 × the chat's file and WAL) free. Below 512 MiB the run stops and the next probe waits 60 s; above it, a chat that needs more than is free is skipped for this launch and the rest go on.
  • At quit, a new first teardown phase aborts every import and waits (at most 2 s) for the job's run, so no drain waits on a whole copy.
  • A failure is logged and never thrown: one chat's (its file can't even be read, or a read of its give-up fails) skips that chat, and any other ends the job for this launch, so it never becomes an unhandled rejection, which would exit orcad. Starting the job cannot fail startup either.
  • When nothing is left, the job removes emptied folders, logs one summary line and clears its interval. On a profile where every old file was copied, the next launch starts no job: the old-file folder is gone and every chat has a row. Where something is kept on purpose (a set-aside or given-up file, a file no record names, an older build's recovery folder, a pre-SQLite transcript), the folder stays, so each launch starts the job again and it walks only those leftovers, a few file checks, then ends; C2 counts them.

#24233's settle-on-open fallback is not removed here. L′-c makes it removable once the job has finished: by then every chat has a row, so startup selects every chat with work left. It stays Temporary in #24233 (its D5) until the open-without-row case is handled, because a chat opened before the job reaches it (during the first 10+ seconds, or listed late) still has no row and relies on its open to settle.

No change for: SSH and WSL (structured chats are local-only); folder workspaces (paths come from workspace and session ids); the wire between clients and hosts; a newer build's read-only database (no job, nothing copied).

Going back to an older Orca after this update. What an older build sees depends on what it can read. An Orca from before the shared chat database (v1.4.218 and earlier) reads only the old per-chat files, so once this version has copied your chats, which it does in the background shortly after the first launch, it finds none of your chats' history (what its chat tabs then show has not been checked). An Orca that has the shared database but not #24233's version 5 opens it read-only (see #24233). Your history is not lost: updating again brings it all back. But anything an older build writes to a chat's old file is set aside when you update again: the next launch's job finds that file, keeps it on disk, and never shows or merges it into the chat. Before this change, both effects applied only to chats you had used since updating. Now they apply to every chat.

Why

Why a background copy, not a copy before startup serves anything or on first read. The common pattern moves legacy data into its database before serving, or on the first read of that item. Here the source is up to ~1,300 external SQLite files. Before serving: Orca measured copying 200 chats adding 1.5 s to the first launch, and copying all 1,090 seed chats taking 16.9 s; those numbers came from an earlier single-transaction importer, and the batched importer here is slower in wall time, so they are a lower bound. On first read only, which is what #23613 does today and stays as the path for a chat used before the job reaches it: statuses fill 4.6–6.6 s after paint on the Mac and old files are re-read for 13.4 s of CPU per launch on the low-spec Windows machine. A set-based copy is one long statement per chat; #23613 measured its single-transaction copy blocking the main thread for 650 ms on the largest chat and moved to 512-row batches because of it.

Why this shape. The job takes the common shape for background maintenance sweeps: no client demand, a fixed interval, a bound on its work checked at every step, a yield between steps, a run skipped while one is still going, and failures logged and never thrown. Its bound is a share of the main thread rather than a per-run budget (D1). Its timing, after startup rather than before serving or on first read, is D0 below.

Why every chat, not only listed ones. The startup cost this PR removes comes from listed chats, and copying only those would drop the folder walk and its leftover handling. But #24233's startup settle selects listed and unlisted chats alike from their rows, so an unlisted chat a crash cut needs a row too, and moving every chat into the one database is what later lets #23613's preview and first-use copy path be retired.

Alternatives considered and rejected. A worker thread with its own connection to the shared database: the shared database is one connection by design, and a second writer would make main-thread appends wait on SQLite's lock, which sleeps the main thread. A worker that only reads and hashes the old file (its own SQLite file, no shared lock): its rows would still cross to the main thread to be inserted, and the verify's fold must read the shared database on the main thread, so the main-thread saving would be small; not measured. Iterating every chat record instead of walking the old-file folder: one file-existence check per record (~1,300) on every launch, forever; the walk costs nothing once the folder is gone.

Differences from the common pattern

  • D0, Intended. The copy runs in the background after startup, where the common pattern moves legacy data before serving or on the first read of that item. Orca-observed: before serving costs 1.5 s for 200 chats and 16.9 s for all 1,090 (from the earlier single-transaction importer, so a lower bound); first read only leaves statuses 4.6–6.6 s after paint on the Mac and 13.4 s of CPU per launch on the low-spec Windows machine (above). The first-read copy (feat(native-chat): one structured-chat journal database per host, owned by one process #23613) stays for a chat used before the job reaches it.
  • D1, Intended. Interval 1 s, and a 15% main-thread share with a 50 ms burst, against the common pattern's longer interval and small per-run step budget. The interval works the same way; the bound is a share of the main thread kept across runs rather than a budget per run. The share is enforced between chats, so any second holds at most the share, the burst and one chat's own work: one very large chat (20k rows, about 1.2 s of work) runs at full speed for its length, in tasks of one batch each, and its debt is paid after it. Orca-observed: the one-time copy is ~17–20 s of CPU on the Mac seed, so at the common pattern's share of the main thread it would take hours, while every launch keeps re-reading old files (13.4 s of CPU per launch on the low-spec Windows machine). The share replaced a per-run budget (8 chats / 200 ms) that let a run overshoot (profiled main-thread share reached 0.28–0.38 in single seconds) and, once paced, ran the copy at about 8.5%. C3–C5 set the final numbers. The cost of the share on a slow machine: on the low-spec Windows machine the copy takes about 6 minutes of wall time (353–381 s at cd50d5cea0b; 347–378 s at de4bc68c520, the same pace). The job's first deletion comes at 43.5–48.6 s from launch, after the tab strip paints at 38.5–44.2 s; the 12–13 old files that go before it are startup's own opens. A session shorter than that finishes the copy on a later launch: 2 of 3 measured launches reached the rig's 420 s cap with 37 and 3 old files left, and the next launch finished them. Until the copy finishes, those launches still read the remaining old files at startup, as today. On a busy machine the streaming gate stretches it further, since the copy takes no time while a chat streams or a send is in flight.
  • D2, Intended. The unit of work is a whole chat, yielding per 512-row batch inside it. The publish must be atomic per chat so no half-copied chat is ever visible; each main-thread task is still one batch.
  • D3, Intended. Status rows of chats already in the shared database are backfilled after startup, where the common pattern backfills a new derived column in the migration that adds it, before serving. Orca's row needs a full replay of the chat's history in JavaScript (about 80 ms for a 20,000-row chat in one task), so a migration that wrote every row would block startup for every chat; the job folds 512 rows per task instead. The gap is feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's (its version 5 migration creates the table empty), filled here because the stack lands together.
  • D4, Intended. A persisted give-up, beyond the common per-run attempt cap. A transient failure is capped per launch, as the common pattern caps attempts per process; a failure no retry reads past is also recorded, keyed to that step's input and the app version, so it is not retried on every launch. Reason: retrying a deterministic failure every launch is an unbounded retry. No Orca-observed failing file or row.
  • D5, Intended. Readers of a chat not yet copied read its old file until it is copied. Orca-observed: copying before startup costs 1.5 s for 200 chats and 16.9 s for all (above).
  • D6, Intended. Teardown aborts imports and waits for the job's run, bounded at 2 s, where the common pattern stops its timer without waiting. Orca closes the shared database at teardown, after every conversation drains; a copy still writing would fail against a closed connection. The wait is one batch.
  • D7, Intended. A chat with work left is settled inside its copy, from the copy's fold, rather than by a separate sweep that re-derives dangling work. Orca-specific cause: the settle needs the chat's fold, the job already holds it, and a separate pass would replay the chat again. It settles exactly the chats the startup settle selects, by the same function.
  • D8, Intended. A free-space guard, which the common pattern does not have. A risk argument: the shared database also holds every live chat, so a copy that filled the disk would make the user's own appends fail. Orca has observed low-disk target machines (the low-spec Windows machine fell to 0.24 GB free, from a test profile and pagefile growth, not from Orca); no Orca SQLITE_FULL has been observed.
  • D9, Intended. The activity gate waits on Orca's startup chat work (tab listing, history restore, settle step) and on main-thread chat activity: a send in flight, or any agent frame within the last 5 s. A turn that is parked on a prompt or silent (a long tool call) does not hold the copy: nothing then runs on the main thread for it. The mechanism matches the common pattern's maintenance gate (a predicate re-derived before each run); the signal is narrower than the common pattern's, which gates heavy database maintenance on active threads and pending prompts and does not gate its backfills. Orca gates this backfill too, on what actually takes main-thread time rather than on turn state, and also stops a chat's copy mid-way at the next frame, because the copy shares the main thread with the stream the user is watching. The cost: on a machine whose agents stream all the time the copy waits, and old files are read as today until a gap comes; the startup signals stay because they compete with the first launch's paint and status fill. The 5 s covers the gaps inside a streaming answer and the moments after a turn when a user reads it and sends again, and is short enough that an idle host resumes soon.
  • Temporary, owned by feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233 (its D5): the settle-on-open fallback. See above: removable once this job has finished, kept until the open-without-row case is handled. No follow-up ticket yet; feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233 tracks it.
  • Follow-up, for the owners of the shared chat database (feat(native-chat): one structured-chat journal database per host, owned by one process #23613 / feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233): checkpoints off the main thread. Not fixed here. The copy's remaining long blocks are single WAL checkpoints inside one commit (disk I/O and the F_FULLFSYNC that durability needs, plus descheduling under load). Their number follows the bytes written (about one per 4 MB), not the number of commits, so no change local to this PR removes them: a larger checkpoint interval makes each one longer, a smaller one adds fsyncs, and an explicit checkpoint does the same work on the same thread. Removing them means checkpointing on a second connection off the main thread, which changes the shared database's one-connection design. No ticket yet.

Linked Issue

Part of the native chat startup work in #23978 and #24233.

Visual Proof

Pending. This changes when statuses appear on the second launch after upgrading; the before/after capture comes from the QA lane on a background $electron rig with an upgraded seed profile. No app was launched for this PR.

Testing

  • tc:node clean at f6248fd585c (merged with feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233 at 0786214b740, which includes main through 5735ee94e19); the head after it, de4bc68c520, changes one test import.
  • Changed-code quality gate passes; oxlint clean on every changed file.
  • New tests: journal-per-chat-file-copy.test.ts, structured-agent-session-per-chat-file-copy.test.ts, structured-agent-session-per-chat-file-copy-host.test.ts (57 tests), plus updates in journal-per-session-import.test.ts, journal-database.test.ts and structured-agent-session-host-teardown.test.ts.
  • What they pin: the publish's row equals what a replay derives, for every case in the status corpus, and the copy's load equals a replay's (a repair still owed included); quit stops a copy between batches and a verify within one batch; a file written after its copy verified is kept and later set aside; the pace between chats, the start delay, the activity gate (including a listing that starts mid-run), listed-first order, the disk guard and its back-off, orphans and recovery folders left alone, set-aside files skipped, the give-up recorded and re-tried only when the file or version changes, transient failures capped at 3, the end; on a real host, every old file copied and the next launch seeding every listed chat at paint, a crashed copied chat settled with no replay, a closed-tab chat with no row given one and settled, a restored chat copied through its owed import with no replay, a send during the copy, and one job per host.
  • Added after review: a chat whose file can't be read is skipped and the rest copy; a failure outside any chat ends the job for this launch without a rejection; a failed read of the chats owed a row ends that phase, logged; a failed start doesn't fail startup; a settle that fails after its copy is logged as a settle and the copy stands; the rows a stopped try staged are deleted 4 per task in the test (512 in the app) and a quit stops between those deletes; a file written while the verify reads the copy back is kept; both of the job's settles refuse a chat this host cannot settle; a row startup drops at boot is not written again by any later launch's job; a chat whose record is gone gets no row and starts no job; a missing row that fails for good is given up on until the chat or the app version changes, and a transient failure records nothing; a chat too big for the free space is skipped while a smaller one copies in the same run.
  • 26 ablations, each turning its test red, plus 12 more for the review fixes (catch per chat, catch per run, the owed-read catch, the start catch, the settle catch, the sliced delete, the stat before the verify, the shared settle rule, the record filter, the give-up write, the give-up exclusion in the owed read, the too-big skip), each red with its test; for example: publish without the row, rosters not settled in the derive, the fold without the repair marker, no abort check in copy or verify, no re-check before delete, gate only per run, no start delay, walk order only, no disk guard or back-off, no set-aside check, no give-up, no transient cap, no folder cleanup, starting while the records file is owed, no settle of a copied chat, an open that replays instead of using the copy's fold, no missing-row phase, the missing-row phase without its settle, the owed import replaying, no walk, no start latch.
  • Related existing suites pass at f6248fd585c: 116 files, 1524 tests (this PR's suites, every suite feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's last two pushes touched, and every suite of the journal store and its write queue); at 5c95a301dfc, 158 files and 1697 tests, including every suite of the files main and this PR both changed (journal database and migrations, status state, import, restore, startup state, reveal, restorer, host teardown, runtime startup, tab restore, launch activation and legacy record import).
  • The copy's gate: structured-agent-session-startup-chat-work.test.ts pins each runtime signal (restoration not yet settled, resolved or rejected; a listing; an owed and a starting history restore) and that a desktop launch's copy waits for restoration, and startup-state.test.ts pins isSettling; 7 ablations, one per signal and its wiring, each red.
  • The pace: structured-agent-session-per-chat-file-copy-pace.test.ts pins that any one-second window (aligned or not, after a long idle) holds at most the share, the burst and the one chat's work that crossed it, that a stall counts, and that quit ends a wait. The job tests pin that a run goes on until the end at the pace's rate, that idle time between runs is not charged, and that every copy and missing row runs inside the chat with that chat's own yield (the missing-row step forwards it). A host test sends to a chat mid-copy with a pace that would wait forever: the send goes through, because the pace never waits inside a chat. The importer test pins a yield between the copy, both verify sides and the publish for a one-batch chat. The pace's tests pin that it never waits inside a chat, pays that chat's debt between chats, and charges nothing while it waits for a chat's lock. 20 ablations, each red. The size bound: journal-per-chat-file-copy.test.ts pins that a batch splits so no part exceeds its share of row JSON (a larger row alone), that both verify sides and the missing-row fold go a part per task, and that a copy commit keeps a whole page and splits only past its own larger share; 6 ablations, each red. startup-state.test.ts pins that the copy's wait for the settle ends when the settle goes on past a recovery that never answers.
  • The copy as the host builds it: structured-agent-session-per-chat-file-copy-production.test.ts restores a listed chat from its old file and starts the copy through the host (reveal, the control, the job) on its real interval. It pins that the restored chat's owed import is charged to the pace (the next chat waits out its debt) but takes no yield of the copy's, so a reader waiting on it through the chat's write queue never meets a pace wait, and, with a pace that owes a wait at every yield, that every wait falls between chats, none inside one; 7 ablations for this shape, each red, including the owed import given the copy's yield again.
  • A chat working on the same host: structured-agent-session-per-chat-file-copy-activity.test.ts, on a real host and its chat activity, pins that no chat's copy starts while a chat streams frames, running or parked on an approval, or while a send is in flight with no frame at all, a send that starts its chat's agent included from accept through hand-over, while a send queued behind a running agent's turn holds nothing; that a chat parked on an approval, or running a turn that has gone silent, lets every chat be copied once no frame has come for the quiet period, and not 1 ms before; that the quiet period runs from the last frame, which no tick need see; that a frame mid-copy, a new turn or a delta that writes no row, stops that chat before its next batch, with nothing published, the chat still owed, nothing recorded and no settle, and that the same chat is copied first once quiet, its staged rows cleared; that a missing status row stops the same way and is written once quiet; and that a chat whose copy starts after a send went in flight starts stopped. claude-structured-provider-frame-activity.test.ts and codex-structured-provider-frame-activity.test.ts pin, with a sink that records every call made on it, that a text delta between checkpoints (both providers) and a Claude thinking delta reach the host's sink only as the noted frame: without the note the sink gets no call at all, which is why the signal is taken where each provider hands a frame to the host and not inside the sink; a Codex approval request is noted at receipt, before anything it writes. The event sink test pins that rows buffered before a bind are noted when the bind writes them. The production test pins the same through the host's own wiring (reveal, the control, the job on its real interval), including a restored chat's owed import stopped mid-way and still owed. It also pins that a stop leaves nothing half-copied: a frame stops a copy at each of its task boundaries in turn (between copy batches, between verify batches, before the publish, and after it), and once quiet the shared database holds exactly the file's rows (epoch, seq, ts, JSON) under one epoch, with the status row the chat had; a stopped missing-row fold later writes exactly the row a whole fold writes. And with a chat streaming frames for the whole test, the copy starts no chat in 120 ticks, while a restored listed chat and a closed unlisted one both show their whole history. Each ablation is red, including a retry that skips the leftover delete, a stop that publishes its staged rows, a gate that holds on a running turn, and either adapter not noting its frames.
  • G3 checks: tc:node clean at 27432f846fe (merged with feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233 at 161001fdef6); related suites pass there, 98 files and 993 tests (the runtime's tab-listing and restore suites included, the page sized by its tasks' time included, feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's startup status-derive suite included, the copy waiting for feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's startup status pass included, the corrupt-history copy and missing-row tests included, feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233's startup status pass, status derive, fill order, saved-session restoration and tab-listing startup suites included, and this PR's copy, activity, host, pace, production and job suites, reveal, the status feed's five, startup state, status derive, the status bridge burst, the main-agent working agreement, restore without import, the importer, the missing-row backfill, the journal store, the event sink, subscribers, attach reconciliation, conversation stop, append delivery, accept-then-deliver, the Codex adapter suites, and every mocked Claude structured-session suite, 52 files); 29 ablations across the final G3 gate and the corrupt-history rule, each red. The real-Claude-CLI test was not run for the one-line change to the Claude adapter: it drives the real Claude login and writes into the real ~/.claude, which this work must not touch; the mocked Claude adapter suites ran instead, and a new one pins that the adapter notes a thinking delta.
  • Cross-version suite: not passing at the default timeout yet. Three tests (resume-marker downgrade, orchestration delivery downgrade, release checkout) time out at 30 s while materializing old release checkouts at machine load 24–37; all three pass with a 240 s timeout. To be re-run at the default timeout on a quiet machine.
  • C3: the paced copy passes on the Mac at cd50d5cea0b (quiet, paired runs; table below): main-thread share p50 0.10, max 0.13–0.18; the 70–82 ms longest task is the rig's mid-copy send starting a Claude session, not copy work. Earlier runs, for the record: at 661dbfeaf2f the gate measured a 13–14 s copy at main-thread share p50 0.42–0.59: the pace was not engaged for the listed chats the startup restore had opened (247 of 446 on the file seed), whose copies ran as their own owed import, unpaced. Their profiles show about 7 s at ~90% with no pace frames, then the unlisted chats at 15%. Fixed at c92ee641f82, which also moved every pace wait to between chats (a restored chat is read through its own write queue, not the host's lock); Local, 120 seed chats, load 9–12: the copy took 17–20 s, share p50 0.17, max 0.25–0.26, per-task CPU max 16–26 ms; a chat opened mid-copy waited 19–40 ms. The joint gate's first runs (before the pace) failed: longest block 215 / 56 / 108 ms and share max 0.58 / 0.50 / 0.71. Their main-thread profiles put main-thread share per second at p50 0.20, max 0.28–0.38 (the rig's share counts every thread in the process). Of the five blocks over 50 ms, two (54 and 56 ms) were splittable work, a copy batch run together with the verify in one task, and are now split; the rest were a stall inside a single commit (a WAL checkpoint, with no profiler samples for 203 ms of the 215 ms block) or GC. Local runs, 120 seed chats (load 7–23): the copy at 15% took about 17–19 s against 32–35 s with the earlier pace inside the per-run budget (about 2 s unpaced); share p50 0.18–0.20, max 0.23–0.27 (earlier pace 0.15 / 0.30–0.33); per-task CPU max 12–14 ms; 437 commits and 47 checkpoints (splitting the copy's commits by size, tried and reverted, gave 1053 and 57); a chat opened mid-copy waited 16–19 ms (unpaced: 15–16 ms; the earlier pace: 202–217 ms). Single stalls inside one commit can still exceed 50 ms; see the follow-up above.

Measurement gate (results at cd50d5cea0b). Arms: main at the merge base, #23978, #24233, this PR; at least 3 interleaved rounds, median of paired differences. Every launch on its own dev profile, isolated HOME and CODEX_HOME, launched in the background.

Row Metric Threshold Mac Windows high-spec Windows low-spec
C1 First launch after upgrading: paint, tab list, status fill, first send 1 s after paint (including a run where the listing ends after 10 s, and a launch where the first window's services hit their 12 s timeout) Within #24233's noise band and no worse than #23978; no chat copied while a listing, history restore or settle is in flight PASS: #24462 = #24233 (file seed, medians: all rows 6.84 vs 6.84 s, paint 3.66 vs 3.68 s, first send 4.76 vs 4.74 s); vs #23978 all rows −0.41 s, paint −0.66 s. In the copy runs, one file went before the first listing in 2 of 3 rounds, by a window's own read, not the job All rows from launch 19.47 / 19.98 / 21.60 s vs #24233 19.69 / 19.98 / 19.12 s and #23978 20.43 / 20.99 / 18.32 s (round 3's PR launched with the box at 21.8% CPU) File seed: all rows 42.75 / 42.60 / 47.00 s vs #24233 42.65 / 46.00 s (its round 3 invalid); paint 30.61 / 30.68 / 35.62 vs 30.29 / 33.71 / 32.47 s; #23978 not measured on this box
C2 Second launch after the copy finished: listed chats settled in the first snapshot; fill vs paint; old-file reads; journal opens at startup; settle opens, CPU, longest task; tab list; leftover folders walked 100% at or before paint; fill ≤ main; 0 old-file reads; settle opens only from this launch's own crashes; tab list ≤ main PASS: 247 / 247 rows at paint on both seeds; tab list 25 / 21 ms vs main 1,833 / 4,608 ms; every row before paint PASS: 247 / 247 at paint (file seed); host seed tab list 19 vs 2,823 ms, every row before paint on both seeds PASS: 0 old files, 447 status rows, database version 5, nothing set aside, no background failures; every row 1.44 s before paint; tab list 98 vs 12,526 ms
C3 The copy run: wall time; main-thread CPU; share of the main thread per second; longest task; event-loop delay p99 (the rig also reports main-thread-only CPU and stalls separately) Share ≤ 25% per 1 s; longest task ≤ 50 ms on the Mac; any Windows task over 100 ms investigated PASS on share: main-thread share p50 0.10, max 0.13–0.18; 445 files in about 69 s. Longest task 70–82 ms, which is the rig's mid-copy send starting a Claude session (no copy frames in the window; idle sends of the same kind: 108–125 ms), not copy work Not measured: no copy-run profile on this box; the copy finished in the C7 run (446 files) Copy wall time 353–381 s (447 files); 12–13 old files went before the job could start, by startup's own opens; the job's first deletion at 43.5–48.6 s. Before pages were sized by time (cd50d5cea0b): share p50 0.16 / 0.16, max 0.49 / 0.96; longest task 438 / 462 ms, one 512-row page's commit (204 / 363 ms) with the next page's read in the same task. Re-measure on the time-sized page pending
C4 A send to another chat mid-copy: click to accepted ≤ the same send idle + 50 ms PASS: every mid-copy send at or below its idle send: another chat 17–24 vs 35–41 ms, the chat being copied 17–41 vs 35–99 ms, a restored tab opened mid-copy 45–91 vs 66–101 ms Not measured: the copy-row rig (mid-copy sends, scripted stream, quit mid-copy) ran on the mac only Not measured: the copy-row rig (mid-copy sends, scripted stream, quit mid-copy) ran on the mac only
C5 Streaming with the copy present: a scripted 200-revision turn, append p50/p99 Equal to idle (the copy takes no main-thread time while frames arrive) PASS: append delay p50 / p99 128 / 418–428 ms vs idle 127 / 430–432 ms; the copy holds through the stream (0–2 files in its first 10 s) and resumes 10–15 s in. With 216 old files owed, a 2,000-delta stream copied none and its CPU equalled idle Not measured: the copy-row rig (mid-copy sends, scripted stream, quit mid-copy) ran on the mac only Not measured: the copy-row rig (mid-copy sends, scripted stream, quit mid-copy) ran on the mac only
C6 Quit mid-copy, closed chat and restored chat: quit time; next launch Quit ≤ #24233's + 100 ms; each chat published once on the next launch PASS: quit −5 to +6 ms vs #24233 (271 / 273, 219 / 213, 210 / 215 ms); next launch: nothing set aside, no background failures Not measured: the copy-row rig (mid-copy sends, scripted stream, quit mid-copy) ran on the mac only Not measured: the copy-row rig (mid-copy sends, scripted stream, quit mid-copy) ran on the mac only
C7 Disk: free space at start, minimum, end; database growth vs bytes deleted Minimum ≥ start − 2 × the largest chat; pauses then resumes at an injected threshold PASS: 670.7 MB of old files deleted, database +664.7 MB, free space at most 143 MB below the start; below the 512 MiB guard the copy pauses, and it finishes once space returns, with no failures PASS: on a 4 GB image held at 361 MB free for 45 s the copy paused (413 files left); once space returned it finished all 446, never below 361 MB free, no failures Free space at most 51–95 MB below the start (671 MB deleted, database +661 MB). Injected threshold not run on this box (run on the high-spec one)
C8 Second launch after quitting at ~50% copied Copied listed chats seeded at paint; the rest no worse than #24233 PASS: 228–231 of 228–231 copied listed chats have their row at paint; the 16–19 still in old files fill 0.07–0.20 s after paint (#24233: 246 chats, 1.93–3.19 s); all rows 3.62–4.30 vs 5.38–7.52 s Not measured: the copy-row rig (mid-copy sends, scripted stream, quit mid-copy) ran on the mac only Not measured: the copy-row rig (mid-copy sends, scripted stream, quit mid-copy) ran on the mac only

The low-spec Windows machine is mandatory: its 13.4 s per-launch cost is why this PR exists; its rows above are measured on it.

The joint first-boot gate (all rows from launch ≤ main + about 0.8 s, #24233's area) passes on the Mac (+0.18 s host seed, +0.17 s file seed, medians) and fails on Windows high spec (+1.23 s host, +0.90 s file) and low spec; #24233 is attributing those, see #24233.

Notes

  • Cross-platform: file deletion, folder removal and free-space probing go through Node's fs and statfs; nothing is platform-specific.
  • SSH/remote: no change; structured chats are local-only. Mixed client and host versions: nothing on the wire changes.
  • Backwards compatibility: the new journal_background_failures table is created at every writable open with no schema version bump. The downgrade cost is stated above.
  • feat(native-chat): store each chat's status with its history; startup seeds and settles from it #24233 drops, at boot, the unsettled row of a chat this host cannot settle. The copy's publish still writes a row for such a chat it copies: a settled row stays, and an unsettled one is dropped at the next boot and derived again when the chat is opened. The missing-row phase writes no row for such a chat, so nothing rewrites a dropped row.

Checklist

  • 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 (pending QA lane)
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (tc:node clean; CI will cover the rest)

…base in the background

Step 2 of L'-c, before the stored-state write is wired: the host's background
copy job (interval, per-run budget, startup-work gate, disk guard, recorded
give-up, listed-first order and walk), the importer's abort checks, copy-side
fold, returned load, never-written file deletion and post-publish re-stat, the
owed import adopting that load, a supplied load in place of the open's replay,
and the teardown phase that stops it first.
…with-history' into brennanb2025/old-chats-copied-in-background

Wires the copy's publish to the reshaped status row (written in the publish
transaction from the copy's own fold), settles a copied chat by the same
unsettled rule the startup settle selects by, and starts the background copy
from the startup step after the settle.
…ound copy, and test the copy

The version 5 migration creates the status table empty, so a chat already in
the host database has no row until it is opened, and startup can't select one a
crash cut. The background copy now writes those rows too: the chat folded a
batch per task, then only derive and write in one short transaction, settling a
chat with work left by the same rule startup selects by. Adds the copy's tests.
…er delete, and settles by startup's rule

- A throw inside a run is logged and never reaches an unhandled rejection: one chat's failure
  (its file's stat, its give-up reads) skips that chat; any other ends the job for this launch.
  Starting the job can no longer fail startup either.
- A settle that fails after a copy published is logged as a settle, not counted as a failed copy;
  the settle selects by the same rule as startup's (a record this host serves), so the job never
  settles a chat startup refuses.
- Rows a stopped copy staged are deleted a batch per task, abort-checked, not in one statement.
- The file's size and mtime are taken before the verify reads it, so a write during the verify is
  read by it or keeps the file.
- The missing-row phase skips chats whose record is gone, and its start and end ignore them.
- A missing row that fails for good is given up on, keyed to the chat's epoch and tip and the app
  version, in the same give-up table as a failed copy (now journal_background_failures).
- A chat too big for the free space is skipped for this launch; the job waits only below the
  512 MiB floor.
- Both of the job's settles (a copied chat, a chat given its missing row) go through the startup
  state's one closed-chat settle, which now takes the copy's load in place of a replay and refuses
  a chat this host cannot settle.
- The missing-row phase skips any chat `canSettle` rejects (record gone, or a provider this host
  does not serve), and the job's start and end ignore them, so it never rewrites a row startup
  drops.
- The copy's publish still writes the chat's status row from its verify fold; #24233's delete of
  that row at the import site is replaced by that write, and its test now pins the write.
…; the status-store doc says who writes an old chat's row
- settleClosedChat keeps #24233's new doc comment, plus this branch's optional load.
- The startup step's callback keeps #24233's gate opener and passes the runtime's startup chat
  work to the copy. The history restore a tab restore owes now lives in that chat-work object,
  which also answers whether it is owed, so the runtime holds one fact instead of two.
…which the first tab listing waits for; its gate's signals each have a test
The copy's wait for the settle still ends when the settle goes on past a recovery that never
answers; a test pins it.
…e chat it holds, and alone sets the rate

- Inside a chat the pace never waits while anyone waits for that chat, and a wait ends when someone
  starts to; the debt is paid at the next yield between chats, which holds no lock. The task
  queue says who waits for a chat. Lock waits, disk probes and a restored chat's own import are
  not charged.
- The copy yields between its last batch and the verify, between the verify's two sides, and
  before the publish, so no chat runs in one task.
- No per-run wall budget or chat cap: the 1 s tick only restarts a run the gate or the disk
  paused, and the pace alone sets the rate.
…huge; only the read-only passes split by size
… copy of old per-chat files

- The copy's failures go to the host's logger, as the rest of the wire layer's now do; the copy's
  summary line stays on the console, since the logger reports only failures.
- The owed history restore keeps living in the startup chat work, with main's logger call.
- The restore wiring keeps passing the host's task queue; the lease-reconcile failures go to the
  logger, as main made them.
#24233 moved the startup step's body into `runStructuredAgentSessionStartup`; it now takes the
runtime's startup chat work and hands it to the step, which starts the copy with it.
Inside a chat the copy's tasks only yield to the next macrotask, so anyone who opens, reads or
commands that chat, through the host lock or a restored chat's own write queue, waits for the rest
of its copy and never for the pace. The pace keeps every task's time as debt and pays it at the
yield between chats, which holds no lock. The give-way machinery is gone: the task queue's waiter
count and the owed import's yield through the journal write queue are reverted to the base.
A restored chat's owed import is charged by its wall time, whoever started it.
…py yield and every pace wait falls between chats
- The missing-row write is #24233's `backfillJournalSessionStatus` now, taken as is; the copy's
  owed-row read and the status give-up key move to journal-session-status-owed.ts, and the size
  bound (`charBoundedBatches`, `IMPORT_BATCH_CHARS`) is imported from #24233's file.
- Quit reaches that fold as a signal: the host database's import abort is an AbortController.
- The startup state keeps the copy's `isSettling` and `loaded`, beside #24233's
  `deriveMissingStatuses`; reveal keeps the copy's control beside #24233's derive-first restore.
… chat works

The background copy of old per-chat files paced itself to a share of the main
thread but kept running while a chat streamed or a send was in flight, so it
competed with the work the user was watching.

It now reads the host's status feed: no chat's copy starts while any chat this
host holds open is working (a turn running, or a send journaled that the
provider has not answered, for every provider), nor until the chats have been
quiet for 5 s, timed from when the work ended. A chat whose copy is under way
when one starts working stops at its next batch through a per-chat signal
composed with quit: the importer, its verify, a restored chat's owed import and
the missing-status fold all take it. Nothing is published, the chat stays owed
and is tried first once the chats are quiet; the stop is not a failure, so
nothing is recorded and no retry is counted. The staged rows go at the next
try's sliced delete. No wait is added inside a chat.
Brings the startup status pass giving each chat its own task, and its tests.
…ts that never go quiet still open whole

A chat starting work stops a copy at every one of its task boundaries in turn;
once quiet, the shared database holds exactly the file's rows (epoch, seq, ts,
JSON) under one epoch, with the status row the chat had. A stopped missing-row
fold writes the row a whole fold writes. With a chat streaming for the whole
test, the copy starts no chat, and a restored listed chat and a closed unlisted
one still show their whole history through the read path a chat not yet copied
takes today.
Brings a listed chat whose startup recovery outlasted its budget opened unverified, with no
second recovery.
… step it can stop, and counts a turn under a prompt as work

A chat starting work now stops the copy before the status derive that precedes
the publish, before the settle of a chat it just copied or gave its missing
row (startup settles that row), and before the old file is opened when work
began between the gate and the chat; the gate is checked again when the chat's
signal is taken, after the walk and the disk probe. A running turn waiting on
an approval or question reads `attention`, not `working`, so the copy used to
resume 5 s into it; the gate now reads the projection's `owesWork`, which holds
beneath a pending prompt.
…agent frames, not to turn state

The copy's gate now holds only while an open chat has a send handed to its
agent and unanswered (queued sends excluded), or any agent frame reached the
main thread within the last 5 s. A turn parked on a prompt, or running a long
silent tool call, no longer holds it; its next frame stops the copy again.

The signal is each provider's frame ingress, not a journal row: streamed text,
reasoning and tool output are gathered for 60 ms and then written only as the
text grows by an eighth, so a long answer goes many seconds between rows while
the main thread parses every delta. Each adapter notes the frame on the chat's
host event sink (new optional noteProviderFrame), which forwards it to the
bound target and the host's subscribers, where the copy listens. The status
feed is back to #24233's; the sink's watermarks moved to their own module.
Every R8-1 stop check stays, and nothing waits inside a chat.
… pinned on a thinking delta

Claude's thinking and tool-input deltas pass through neither the shared text
coalescer (only text deltas are gathered) nor a row write (stream frames write
none), so only frame ingress sees them. The adapter's frame delivery now adds
one call and keeps its condition as it was.
…delta reach the sink only as a noted frame

A sink that records every call shows these frames make no other sink call, so
noting activity inside the sink would never see them; the adapter hooks stay.
…hat copy until it is handed over

A send to a chat whose agent was not running read as queued (not yet handed
over) until the agent started, so the copy could run through the spawn, the
history drain and the hand-over. A send not yet handed over now counts as in
flight unless the chat's agent is ready, so only a send waiting behind a
running turn or prompt is left out. The sink also notes activity when a bind
writes rows that frames left before it, since those frames were noted to no
one. A Codex approval request is now pinned as noted at receipt.
Brings the startup status pass writing its rows a slice at a time, filling the
chats on screen first, and its fixes.
Brings a corrupt rowless chat getting no row from the startup pass, so its rebuild still runs.
…round copy either

#24233's startup pass leaves a corrupt history rowless, so the next boot opens
it and its rebuild runs. The copy now follows the same rule: a copied chat
whose fold is corrupt is published with no status row, and the missing-row
phase folds with #24233's foldJournalSessionStatus, writes no row for a corrupt
fold, and gives the chat up (the existing 'status' give-up, keyed to its epoch
and tip plus the app version), so later launches neither re-fold it nor start
the job for it until its rows change, which its rebuild does.
Brings the status backfill module down to its fold and its batch write.
… as the job does, now that #24233 dropped its one-chat wrapper
@brennanb2025

brennanb2025 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Review status: paused, to be superseded (do not merge)

Decision. This PR is paused. A follow-up to #24127 will copy each old per-chat file on that chat's first read, with restore's read of a listed tab counting as a first read. That follow-up supersedes this PR.

  • Why it wins: the common pattern moves legacy data before anything reads it. Copying on first read keeps that rule for each chat. This PR's background copy breaks it, because chats are read from their old files (preview reads, owed copies) before they're copied, and that's where most of the bugs found in review came from.
  • What carries over: the follow-up takes this PR's measured pieces and credits it.
  • When this closes: only once that PR is up, with a link here.

#23978 + #24233 land safely without this PR.

What this PR proved, carried into the follow-up

  • Time-sized pages. A fixed 512-row page was one 438–462 ms main-thread task on the low-spec Windows machine. Pages now aim for about 25 ms each, with the read and the write in separate tasks, and stay full-size on fast machines. A user's own open keeps whole pages, as before.
  • Delete safety. The file's size and modification time are recorded before the copy is checked, so a file written meanwhile is kept.
  • Corrupt histories. A corrupt chat's rows are copied without a status row.
  • Quit and stop. A quit stops the copy within one batch, with stop checks before every batch, status write, settle and file open.
  • No second replay. The status row is written in the publish transaction, from the fold the check already built.
  • Leftover cleanup. Rows left by an interrupted copy are deleted in slices.

Final head 27432f846fe. Typecheck clean; 993 tests pass. Each fix has a test that fails when the fix is removed.

Measured on cd50d5cea0b. The Mac copy rows pass:

  • main-thread share 0.13–0.18;
  • sends during the copy at or below idle;
  • streaming during the copy equal to idle.

Low-spec Windows:

  • streaming during the copy passes;
  • the 438–462 ms tasks were found there, and the fix above was not re-measured before the pause.

M4Air live QA of the copy: all scenarios pass. See the QA comment above for what was copied, the corrupt-chat handling and the quit-mid-copy resume.

For the follow-up

  • Quit was slow mid-copy: with a copy in flight it took 3.5 s, against 0.3–0.4 s idle.
  • Test processes outlived quit: these were the startup model-list check's fake agent, which in the QA rig never exits when its input ends (a rig fault). The product side, quit leaving a hung check behind, is tracked in STA-9254.

Not verified: the real Claude CLI test (it writes the real user profile; skipped by decision), and Linux and SSH hosts.

The copy's startup gate covered the tab listing, history restores, restoration
and the settle step, but not #24233's later startup status pass, the fold of
the listed chats with no status row after paint. On the Windows high-spec host
seed the copy imported unlisted old files inside that pass, costing it
200-300 ms of main thread. The startup state now reports the pass in flight
(isDeriving, from its own calls still running, beside isSettling), and the
host's gate holds while it does.
…ot a startup-state flag

The startup status pass runs at the front of restoreReadableSessions, before
the restorer reports restoring. Reveal now counts that call from entry to its
end and holds the copy while one runs, so the derive pass and anything later
added to the call are covered without a new signal on the startup state, which
is back to #24233's.
Brings the startup status slices committed unsynced and the derive pass yielding only before
chats it folds.
…last task took

On the low-spec Windows machine the copy's longest task was 438 / 462 ms, one
page of a per-chat file: its 512-row commit (204 / 363 ms) and, in the same
task, the next page's read and batching (about 195 ms). A fixed page is fine on
a fast machine and far too big on a slow one.

Each page is now read in a task and written in a task of its own, and a page
size per copy follows each task's wall time toward 25 ms: a task over it halves
the next page, two tasks in a row under half of it grow it a quarter, within 8
and 512 rows. The leftover delete and both verify sides read the same page; the
char ceilings still split a page of huge rows. A fast machine keeps whole
512-row commits; the publish, the verify and every stop check are unchanged.
…ever an earlier one shrank it to

The page size is measured within one copy, in memory only, and nothing carries it to the next
chat or launch.
… in flight is read once per journal tip

A user's own first open, and a restored chat's owed import a read pays, take
whole pages as on main. The importer takes its pages as an option, whole by
default; the job passes time-sized pages for its own imports, and so does the
owed import when the job pays it (with its signal).

Whether a chat has a send in flight was re-derived on every gate check by
walking all of its submissions, 12-14 ms per check on the low-spec machine. It
is now read again only when the chat's journal tip, its fence or its agent's
readiness moved (every send and answer is a row, so a new one moves the tip),
kept per journal and gone with it. The cold-send rule is unchanged.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Live QA on a real Mac: the background copy of old per-chat chat files

Bottom line: I found no user-visible problem. Every chat was copied once into the shared chat database with every row intact. The old file was deleted only after its copy. Nothing was duplicated or lost. A quit in the middle of the copy resumed cleanly on the next launch.

Two things behaved differently from what I first expected; both are explained below. One corrupt chat is handled by Orca's existing repair before this job reaches it. Also, one or two files go through startup restoration rather than this job.

What was tested

  • Builds: the PR built on the M4Air Mac.
    • First pass at dcf184601bb.
    • Follow-up pass at 972283067bb. The current head efd66d33db4 only adds a test on top of that.
  • App setup:
    • An isolated dev profile with its own HOME and Codex home.
    • The window stays hidden, and screenshots are taken through the DevTools protocol.
    • A fake claude answers every chat. No real agent ran.
  • The seed is an "upgraded user" profile with 32 structured chats whose history is still in old per-chat files (agent-session-journal/<workspace>/<chat>/journal.db):
    • 24 short chats in a git repo and 4 short chats in a folder workspace (not git).
    • L-1, a long chat with 2,115 rows.
    • X-1, a chat with a tab whose file has one middle row removed, so its history reads as corrupt (a sequence gap).
    • U-1, a chat whose tab was closed. In the follow-up seed U-1 is also corrupt (gap at row 5).
    • O-1, the tab left open and visible.
    • One more chat (K-1) stays in the shared database. It is used to keep a chat streaming.
  • How the seed was made: the chats were created through the app, then moved out of the shared database into old-format files. The move uses the same steps as the PR's own test helper, moveChatToPerChatFile.
  • How results were counted: every count below comes from a script that reads a copy of the database. Per chat it checks:
    • whether the old file still exists;
    • how many history versions (epochs) the chat has;
    • whether the rows are byte-identical to the old file;
    • any unpublished leftover rows;
    • the stored status row;
    • any give-up record;
    • duplicate tabs.

1. Before the copy: each chat opens with its full history

Expected: every seeded chat opens with its full history while its old file still exists.

Observed: pass. K-1 was kept streaming, which holds the copy back. While it streamed, the long chat, a short repo chat, a folder chat and the open tab each opened with their full history, and each one's old file still existed when it was opened.

Long chat L-1 before the copy (left) and after the copy and a relaunch (right). Both long turns ("FIRST DONE") are there, and nothing is repeated:

Before the copy After the copy
L-1 before L-1 after

2. The copy runs with a chat open, and finishes

Expected:

  • The main log shows the summary line.
  • Each old file is deleted only after its chat is copied.
  • One epoch per chat, with row counts equal to the old files.
  • No duplicates.

Observed: pass.

  • Log: [structured-agent-session] old chat files copied { copied: 30 }.
  • Files: all old files are gone, and the old folders were removed.
  • Rows: 31 of 32 chats have one epoch whose rows are byte-identical to their old file. That includes L-1 at 2,115 of 2,115 rows and the closed-tab chat U-1.
  • Leftovers and duplicates: 0 unpublished rows, 0 duplicate tabs, and no duplicate chats in the sidebar.
  • The open tab: O-1 stayed visible and complete throughout.

O-1, the open tab, at launch while its history was still in its old file:

O-1 at launch

Where startup restoration comes first. One or two files leave through startup restoration before the job starts. These are the visible tab and any tab whose history is corrupt. The file count went 32 → 31 → 30 before the job copied the rest in four runs, and 32 → 31 in the other two.

The corrupt chat with no tab (U-1) goes down the job's corrupt path:

  • Its 7 rows were copied byte-identical, with no status row.
  • The log shows a chat with no status row has a corrupt history with U-1's id.
  • One give-up row was recorded: step = status, keyed to the chat's epoch and last row.
  • It is not retried. A relaunch logged nothing for it, and the give-up row was unchanged (same failed_at).

The corrupt chat with a tab (X-1) never reaches the job. Startup restoration opens it first:

  • The restore cannot preview a corrupt history, so it copies the chat right away.
  • The open then cuts the history at the gap and settles the cut-off turn: Claude stopped while this response was in progress.
  • The result is 13 rows with a normal status row and no give-up.

This is the open-time corruption handling main already has (journal-store-restore.ts); this stack only adds a status write to it. I checked that in the code; I did not run main in this QA. The chat looks the same before and after the copy:

X-1 before the copy X-1 after the copy
X-1 before X-1 after

3. After the copy: same history, nothing missing or doubled

Expected: after a relaunch, every chat opens with the same history as before the copy.

Observed: pass.

  • After a relaunch, S-24, L-1, F-4, O-1 and X-1 each showed the same history as before the copy (X-1 is shown above). S-1 and F-1, which had no before-copy screenshot, showed their one complete turn.
  • No turn was repeated.
  • The relaunch copied nothing more.
  • The database census was unchanged.

4. Quit in the middle of the copy, then relaunch

Expected: a quit stops the copy partway, and the next launch copies the rest once, with nothing duplicated, lost or left half-copied.

Observed: pass.

  • The quit: a graceful quit was sent the moment the old-file count dropped to 18. Three more short chats finished in the next 30 ms, then the copy stopped with 15 files left.
  • After the quit:
    • S-1…S-15 were fully copied.
    • S-16 onward, L-1, U-1 and the folder chats were untouched in their old files, with nothing in the shared database for them.
    • There were no unpublished leftover rows.
  • On relaunch:
    • The log showed old chat files copied { copied: 15 }, plus the U-1 corrupt-history warning.
    • Every chat was then copied exactly once with rows equal to its source.
    • There were 0 old files and 0 duplicates.
  • Quit time: the app took 3.5 s to exit with a copy in flight, against 0.3–0.4 s on an idle quit.

S-16, which was still in its old file at the quit and was copied after the relaunch, shows its one turn once:

S-16 after relaunch

5. The copy waits while a chat is streaming

Expected: the copy takes no time while any chat streams, and starts about 5 s after the stream ends.

Observed: pass.

  • First pass: K-1 streamed for about 2 minutes. During that time, every chat I opened still had its old file, so the job had copied none of them yet. After K-1 stopped, the job copied the remaining 27.
  • Follow-up pass:
    • K-1 streamed from 10:36:45 to 10:38:22.
    • The copy's summary line was written at 10:38:27, about 5 s after K-1's last activity.
    • The summary reported all 31 remaining files copied.

Caveat: my file-count watcher stopped early in every run (a rig problem). So these times come from the agent log, the moment the summary line was written, and the per-chat checks. I have no per-file deletion timeline after a stream.

Isolation

  • Every launch: the isolation check on the real Electron main process passed. HOME and Codex home were in the rig, the keychain was mocked, and no token variables were present.
  • No real agent ran: the session-environment check showed no real claude run, and every chat turn went to the fake claude.
  • The real ~/.codex on the M4Air:
    • config.toml and hooks.json hashes were unchanged from the start of this QA to the end.
    • config.toml had already changed at 00:56 that morning, before this QA started; that change was not ours.

Not verified

  • Windows and Linux.
  • SSH hosts.
  • A real Claude or Codex CLI. Every chat was answered by the fake claude.
  • Corrupt-history handling on main (read in the code, not run).
  • A per-file deletion timeline after a stream (see the caveat in section 5).

…o answer

On the Windows high-spec machine the copy imported the unlisted old files from
11.7-11.9 s after launch, before the first paint at 16.2-16.4 s: startup
restoration had settled and no listing was yet in flight, so nothing held it.
The startup chat work now also holds the copy until a first tab listing has
answered. A host no client lists (an orcad no client attaches to, a headless
host) opens after a bounded 60 s wait from startup, so it still copies.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing this paused migration branch. Main removed the old per-chat import paths in #26038, and there are no existing native-chat users who need this backfill.

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