Skip to content

feat(native-chat): one structured-chat journal database per host, owned by one process - #23613

Merged
brennanb2025 merged 73 commits into
mainfrom
brennanb2025/chat-journal-appwide-db
Sep 29, 2026
Merged

brennanb2025 merged 73 commits into
mainfrom
brennanb2025/chat-journal-appwide-db

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 142 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​4117 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1787 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2330
Prod 84 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1997 $\color{#cf222e}{\Huge{\mathbf{−}}}$​833 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1164

The problem

Every structured chat kept its history in its own small SQLite file, agent-session-journal/<workspace>/<session>/journal.db, with its own connection that opened, closed and retried closing on its own schedule. That per-chat handle lifecycle produced a run of bugs: handles leaked and blocked renames on Windows (#18652), quit closed files while cleanup still wrote to them (#18974), and removing a chat directory failed with ENOTEMPTY while handles were open (#18824).

Nothing also stopped two Orca processes on one profile from writing the same chat. Dev builds skip the single-instance lock, so two pnpm dev runs on the default profile both opened every chat. Opening a chat settles its handed-over sends and running turn without a lease (structured-agent-session-conversation-open.ts), and each process numbers a chat's rows from its own memory (journal-row-writer.ts). Measured on main, the second process marked the first one's live send unknown, and the first process's next row failed with UNIQUE constraint failed.

What changes for users

  • Chat history on a profile lives in one file, agent-session-journal.db, opened once by the running Orca. Nothing a user does changes; the per-chat handle bugs above can no longer happen.
  • Existing chats keep their full history. Each old per-chat file is copied into the shared file the first time that chat is opened or written, checked row by row against the original, and only then deleted. Startup copies nothing, so the first launch after upgrading is no slower than main. The first use of each old chat pays a one-time copy (see the measurements).
  • A second dev copy of Orca on the same profile now stops at launch, the way packaged Orca and orcad already do: it brings the first window forward, prints one line naming the profile and ORCA_DEV_USER_DATA_PATH, and exits. Running two dev builds side by side needs ORCA_DEV_USER_DATA_PATH set for the second one.
  • If the shared file is damaged, the app still starts normally: terminals, tabs and saving work, chat tabs stay, and each chat says "Unable to load this chat." The file is left exactly as it is.
  • If a newer Orca wrote the shared file (after a downgrade), chats open read-only with their history visible, and sending says "Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them." The file is left exactly as it is.

Scope

  • One structured-chat journal database per profile (per orcad data root on a remote host): agent-session-journal.db, one connection, owned by the process that holds the existing single-instance lock.
  • Dev desktop takes the same process-level single-instance lock packaged builds use (shouldSkipSingleInstanceLock). Serve mode, the macOS diagnostic bypass and e2e launches behave as before.
  • Rows keyed by (session_id, epoch, seq) in a rowid table, the same declaration main's per-chat files use.
  • A lazy per-chat importer: startup reads an old per-chat file read-only to list the chat, and the copy runs just before the chat's first read or write, in short batches, verified row by row, then the old file is deleted. A per-chat file that reappears after its chat was copied (an older build ran in between) is set aside: never copied over the chat, never deleted, recorded so it is not read again.
  • A damaged or unopenable journal refuses chats only; the rest of the app starts normally and chat tabs are never dropped (the tab list reports "can't say" rather than "none").
  • A newer-schema journal opens read-only, with its own refusal reason so every client shows "update Orca".
  • checkpoint_fullfsync = ON beside synchronous = FULL, so checkpoints reach the disk on macOS.
  • Deleted: the per-chat handle, close and close-retry machinery (journal-close-retry.ts, journal-store-close.ts, the per-store open/close contract, recovery close-on-throw, teardown's close fan-out).
  • Not in this PR: moving the agent record store into SQLite, recovery onto a fresh file after SQLite-level damage, per-commit fullfsync, group commit, the saved chat-list status (it moves to the lazy-history PR next to the code that reads it), and any retention or deletion policy.

How it works

  • One connection. The host opens agent-session-journal.db once at install. A chat's store holds no connection; closing a conversation drains its admitted writes, and the one connection closes last at teardown, kept until the close actually succeeds. Every transaction goes through one helper that includes COMMIT: a failed commit is rolled back with its original error kept, and if the rollback fails too, chats answer a typed try-again refusal instead of the connection being stuck.
  • Rows. An append is one INSERT keyed (session_id, epoch, seq). Replay scans a chat's epoch in order; a replace, rollover or rewind deletes that epoch's range in the same transaction that publishes the new one.
  • Old chats. Restore reads a not-yet-copied per-chat file read-only, a batch at a time between event-loop turns, and closes it before moving on; that read lists the chat, publishes its status and keeps its restart offer, at the cost main pays to replay it. The copy is owed work on the chat's own write queue: it runs before the chat's first write, and a window's first read waits for it. Rows are copied in 512-row batches under the file's epoch, which no pointer names yet, so no reader sees a half-copied chat; a copy that stopped midway is cleared on the next try. The copy is then read back and compared with the file, row by row and by content; only on a match is the chat's pointer and import marker published and the per-chat file deleted (after its handle is closed). A mismatch keeps the file and refuses the chat. The copy batches commit without a separate disk sync; the publish commits with a full sync, which makes every earlier batch durable. A pre-SQLite transcript in the same folder is kept. The chat tab each workspace last had open is read by the window at startup and so is copied then, after the chat list is ready; every other chat waits until it is used.
  • One process per profile. Packaged Orca and orcad already refuse a second process at startup. This PR makes dev desktop take the same Electron single-instance lock, so no second process ever opens the journal.

Why this design

The shared file removes the per-chat handle lifecycle and every bug that lived in it, instead of adding more guards around it: there is one connection, opened once and closed once. One process per profile, enforced at launch by the lock Orca already uses for packaged builds, removes the two-writer corruption at its source for every store, not just chats. Copying old chats lazily, on first use, keeps the first launch after upgrading no slower than main; the check against the original before deleting it means an upgrade can never lose history silently.

Alternatives considered and dropped during review: a chat-only lock with a refused second process that could take the chats over while running (it guarded only dev builds, and the in-process takeover produced races that three review rounds kept finding); rows keyed in contiguous blocks per chat (a measured rewind win, but no observed failure, and neither reference does it); an after-every-delete free-page pass (neither reference does it; reclaiming belongs with the retention work); saving each chat's list status in this PR (nothing reads it yet).

Differences from the common pattern

Each difference is labelled intended (safe to keep, with why) or temporary (what removes it). One process per profile and one database per data directory are not listed: both match.

  • Temporary. Old data is copied lazily, one chat at a time on first use. The common pattern migrates everything at startup, before serving. Ours keeps the first launch after upgrading no slower than main with 1,000+ old chats. The importer and the read-only reading of per-chat files go away in a later release, once old per-chat files are gone.
  • Intended. A journal written by a newer schema opens read-only with its history visible. The common pattern does not check the schema version at all. Ours never writes a file a newer Orca owns; main already opened newer per-chat journals read-only.
  • Intended. Logical damage one chat's replay finds (a gap, an unanchored prefix, a malformed row) still rebuilds that chat alone from its provider transcript, using main's existing code. The common pattern fails the whole server on a damaged database. SQLite-level damage is not recovered here either: chats say "Unable to load this chat." and the file is left as it is, while the rest of the app keeps working.

Known costs and limits

  • First use of each old chat after upgrading is slower than main, once. It copies and checks the chat: on the 1,090-chat copy, p50 11 ms against 4.3 ms and p99 361 ms against 91 ms (first boot); the largest chat (68 MB) took 1.2 s against main's 0.39 s to open the first time. No single main-thread task is longer than main's. After that first use, the chat opens from the shared file.
  • Rewinding a very large chat can be slower than main. For the largest real chat (68 MB) whose rows were written in one go, a rewind takes 231 ms against main's 476 ms. If its rows were written interleaved with 19 other large, busy chats, it takes 835 ms (about 1.75× main).
  • Development-build profiles. A profile that ran an earlier development build of this PR has a journal in an unreleased format. Chats there say "Unable to load this chat." and the log says to move the file aside; chats those builds had already copied lose their history. No released build wrote that format.
  • Stop on a read-only chat with a queued message (a newer Orca crashed with a message queued, then the user downgraded) says "couldn't confirm" rather than "update Orca".
  • An older Orca run after a chat has been copied finds no per-chat file for it and shows that chat with no history; chats never used since the upgrade are unaffected. On the next upgrade the chat shows its full history.

Measurement gate

G1: census

Bug Verdict How
#18652: per-chat handles leaked; Windows renames blocked gone No per-chat connection exists; one connection opens once and closes once
#18974: quit closed journal files while cleanup still wrote gone (closed file), covered (ordering) Teardown drains admitted writes, then closes the one connection, kept until the close succeeds
#18824: ENOTEMPTY removing a chat directory with handles open gone No per-chat directory is created; an old one is deleted only after its handle is closed
Two dev instances settle each other's running turns gone A second process on the profile stops at launch
A non-database journal file never recovers same class, larger radius A damaged shared file refuses every chat (the rest of the app works); the file is left untouched

G1: scenarios (fault injection, same host and store API on both builds)

Measured on the first reviewed head of this PR; the storage paths these exercise (commit, crash, open, corruption) are unchanged by the later redesign except two processes, which the process lock now stops before either opens the journal.

Scenario Main This PR
Crash mid-write pass pass
Kill during open or import pass pass; a killed copy leaves the old file and the next open copies it exactly once
Two processes on one profile FAIL: the second marked the first's live send unknown and broke its next write the second process exits at launch
Disk full pass pass, with the real "database or disk is full" error
Corrupt page under one chat that chat's send refused same
Non-SQLite file FAIL: that chat never recovers every chat refused "Unable to load this chat.", file unchanged, app running
Quit during failure recovery pass pass
Close racing queued writes pass pass

G2: performance, macOS

Real host, record store and journal on a read-only copy of a real profile (1,090 per-chat journals, 1.83 GB); only the provider process is faked. This PR at 94182a8a21b (the current row layout) against main a68d62911e, median of 3 (rewind: median of 5). The disk was 98-99% full during these runs, so single runs varied widely; the ranges are in the review notes.

Metric Main This PR Threshold Result
First boot after upgrade: restore 200 chats + listing 2,122 ms 2,258 ms ≤ main × 1.10 PASS
First boot after upgrade: longest main-thread block 382 ms 61 ms ≤ main PASS
Second boot: restore + listing 1,192 ms 1,271 ms ≤ main × 1.10 PASS
Second boot: longest main-thread block 182 ms 30 ms ≤ main PASS
First use of an old chat after upgrade, p50 / p99 4.3 / 91 ms 11 / 361 ms ≤ main × 1.10 or + 5 ms FAIL (one-time per chat; see known costs)
First use of an old chat: longest main-thread block 85 ms 64 ms ≤ main PASS
Send to accepted, p50 88 ms 87 ms ≤ main × 1.10 PASS
Append p99, 1 streaming chat 1.0 ms 2.6 ms ≤ main + 1 ms FAIL by 0.6 ms (noisy: main's own range 0.6-2.7 ms)
Append p99, 20 streaming chats 8.3 ms 9.6 ms ≤ main + 1 ms FAIL by 0.3 ms (noisy: main's range 2.4-65.8 ms)
Rewind of the largest chat (68 MB), rows written in one go 476 ms 231 ms ≤ main PASS
Rewind of the largest chat, rows interleaved with 19 busy peers 476 ms 835 ms ≤ 4× main PASS against the gate; slower than main (see known costs)
Disk after every chat is used 1,885 MB 1,866 MB ≤ main × 1.10 PASS

G2: performance, Windows

Measured on an earlier head of this PR (67f5e35adec), which already copied chats lazily but still used the block row layout. A re-measurement on the current layout is running and will replace this table. Windows low-spec machine, the fixed 247-chat seed, the real app, against main a68d62911e on the same machine, median of 2.

Metric Main This PR Result
First boot after upgrade: chat listing ready 51.7 s 38.1 s PASS
First boot after upgrade: first paint 83.6 s 71.2 s PASS
Second boot: chat listing ready 38.5 s 35.5 s PASS
Second boot: first paint 53.6 s 52.7 s PASS
Append p99 at 1 / 5 / 20 / 50 streaming chats (high-spec machine, real writers) 4.20 / 4.07 / 3.62 / 2.85 ms 4.07 / 4.02 / 3.79 / 3.08 ms PASS (group commit not needed)

Linked Issue

N/A (maintainer PR).

Visual Proof

Live Electron QA of dev builds on a separate Mac with real Claude chats, from fresh profiles seeded by main, at 5ca53728160.

First boot after upgrading: a chat main wrote opens with its history intact, and a new message is answered:

imported chat after upgrade send in the imported chat

A damaged journal file: the app starts normally, tabs are kept, and each chat says "Unable to load this chat.":

damaged journal

A journal written by a newer Orca: history stays visible, and a send says to update Orca:

newer journal, send

Testing

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

Live Electron QA ran on a separate Mac with real Claude chats, main against this PR, from fresh profiles seeded by main: first boot after upgrading (all chats listed; a chat's per-chat file untouched until it is used, then copied exactly and deleted; a closed chat copied only when reopened; a send answered); a second dev launch on the same profile (exits with code 3 and its one line, the first window stays up, the journal files unchanged); a damaged journal (the app starts with no error toast, tabs kept, "Unable to load this chat.", the terminal works, the file unchanged, one log line); a newer-schema journal (history visible, sends say to update Orca, the file unchanged); quit mid-reply and relaunch; an idle-stopped chat, then a send; and boot to first paint, where six paired runs showed no measurable difference from main. All passed.

Earlier heads were also tested with real Codex chats merged with #23552: import from main, a 16-turn rewind (correct after relaunch), quit mid-turn and idle stop.

New tests, each ablated (the core line removed, and the test turns red for that reason):

  • the one connection: a failed COMMIT rolled back so another chat can still append; a failed close keeping the connection until a retried close succeeds; a failed install closing the connection it opened; checkpoint_fullfsync read back on;
  • rows: replay order, epoch replace, rollover and rewind as range deletes by (session_id, epoch);
  • old chats: startup copies nothing except chats it must settle, and closes every per-chat file it reads; the first read or write copies the chat in batches with none of it visible until published; a copy stopped midway is cleared and copied again; two uses of one chat copy once; a copy whose row content differs keeps the file and refuses the chat; a verified copy deletes the file; a crash between publish and delete finishes on the next open; a reappearing per-chat file is set aside and never read again; a file with no tables opens as an empty chat; a non-database file is refused and kept;
  • refusals: a damaged journal starts the app, keeps chat tabs ("can't say" inventory) and refuses with "Unable to load this chat."; a newer-schema journal opens read-only and every client shows "update Orca"; an unreleased-format journal is refused without retry; an old client reads the new refusal reason as its generic words and never rejects it;
  • dev single-instance lock: the dev, packaged, serve and e2e decisions;
  • restore yields between chats.

Existing reducer, identity, cursor and row-schema suites keep their assertions. tc:node and tc:web pass.

AI Disclosure

Built with Claude.

Review

Author: @BrennanKB5

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: orcad owns its own data root, takes its own existing startup lock there, and holds its own journal file; nothing crosses hosts. Folder workspaces: the journal is keyed by session and workspace id, never by a path, and QA covered chats in a folder workspace. Mixed versions: one new refusal reason (journalWrittenByNewerOrca) and one optional field on the chat tab list (agentSessionsUnverifiable). An older client ignores the field and shows its generic "couldn't read this chat's saved history" words for the new reason; it never rejects the reply.

Checklist

  • This PR is small and focused
  • I explained what changed and why (the problem, the user-facing change, 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)

…ed by one process

Every structured chat on a state directory now lives in one SQLite file,
agent-session-journal.db, opened once by the process holding
agent-session-journal.owner: an empty SQLite file whose held BEGIN EXCLUSIVE is a
kernel byte-range lock, refused while another process holds it and released when
the holder dies.

- Stores own no connection: the per-chat handle, its close contract and the
  close-retry registry are gone; closing a conversation drains its writes, and the
  one connection closes last at teardown.
- The owner lock is taken at runtime start, before orca-runtime.json is written;
  a process that does not own the chats is not published and refuses every
  structured request with journalUnavailable and words that say what to do. It
  retries the lock with backoff and runs the full install once it holds it.
- A journal that will not open fails the host install: every chat says "Unable
  to load this chat." (journalCorrupt), and nothing is renamed, deleted or
  rebuilt. A newer build's database is refused and left byte-identical.
- An append is one INSERT. The listing status is a column, written after the
  rows it describes and keyed by (epoch, sequence).
- A per-chat journal from an earlier build is copied in verbatim (epoch UUID and
  every sequence) on that chat's first open, and its directory is retired only
  after the copy commits.
- auto_vacuum = INCREMENTAL, with freed pages handed back in bounded steps after
  every delete.
…ogether

Each chat's live epoch owns a block of row ids, block * 2^32 + seq, so a chat's
rows share leaf pages with nobody else's, a replay is one range scan, and
replacing or rewinding a chat deletes one contiguous range. Measured on the
largest real chat (61 MB) beside 19 interleaved peers: 39 ms and 7.5 MB of WAL,
against 214 ms and 102 MB for a (session_id, epoch, seq) key.

- Ids are computed in Number arithmetic, never bitwise. A sequence is refused
  outside [1, 2^32) and a block at 2^21, which keeps every id below 2^53.
- A replace, roll or import allocates a fresh block, moves the chat's pointer,
  and deletes the old block in the same transaction, so no orphan block exists.
- The listing status write moves into its own writer beside the column.
… Orca wrote it after a downgrade

A per-chat journal.db that reappears after its chat was copied in is the newer
history: an older build, run after a downgrade, attached the chat and wrote it.

- journal_imports records the (epoch, tip) each chat was copied from, in the
  copy's own transaction. A file already copied is never copied again, across
  any number of restarts after a failed rename; a file that differs always is.
- Newest writer wins, per chat, with a row saying the chat was continued in an
  older version of Orca. When both builds wrote past the recorded tip under one
  epoch, the copy takes a fresh epoch, so readers reset instead of skipping rows.
- Each copied directory retires to its own .imported-<epoch8>-<ms> name, so a
  second downgrade and re-upgrade never collides with the first.
Attach-flow and reconcile-attach fixtures stop passing a journal database those inputs do not take, and host and restore fixtures pass the one they now require instead of the removed journal root.
… chat journal

A process refused the chat journal, because another Orca owns it or because its own journal will not open, failed startup restoration: the window booted in degraded no-save mode and a paired phone could not list any tabs. Startup restoration now treats the refusal structured requests are getting as having no structured host; terminals, tabs and saving go on, structured requests are still refused by the gate, and the install is retried on the next one. Any other install error fails startup as before.
…s are refused

Session history (listing and preparing a resume) and a terminal typing a resume command only check whether a structured chat owns a provider session. In a process refused the chat journal they failed outright. They now take the refusal chats are getting as having no structured host, the same treatment startup restoration gets, through one shared helper; any other install failure still fails them. Chat requests keep the gate's refusal.
…ts schema

A crash between creating a chat's journal.db and creating its tables left an empty or schema-less file. Each chat used to open that file as an empty chat; the importer instead refused the open as "try again" forever. A file with no journal_sessions table is now read as never written, the same as one with no rows. A file that is not a database, or whose read fails, is still refused.
… restore

Opening a chat's journal is synchronous SQLite now that no per-chat directory
is created first, so the restore of every visible chat ran as one main-thread
task. Each chat now waits for a macrotask before it opens.
The one-time copy of an earlier build's per-chat journal ran as one
transaction, which blocked the main thread for 650 ms on the largest chat.
Rows now copy 512 at a time, each batch its own transaction, yielding to the
event loop between batches. The rows go into a block journal_import_blocks
reserves, which no reader follows and no other chat is allocated; the last
batch publishes the chat's pointer, repair marker and import marker together
and releases the reservation. A copy that stops midway leaves only that
block, which the next open clears and copies again. Two opens of one chat
import one after the other.
A lock file that is not a database, or cannot be opened, made the claim throw
before any refusal was recorded, so startup restoration failed on every
launch. The claim now sits in the same try as the database open and records
the same typed refusal.
…ning pass

A reclaim pass ended as soon as the freelist stopped shrinking between steps,
so a second delete that freed more than one step's worth mid-pass ended it
early and left those pages on the freelist. A pass now ends only when a step
itself frees nothing, or the freelist is empty.
…o update

A chat opened read-only because a newer Orca wrote rows to it answered a send
with the generic write failure. It now refuses the way a database a newer Orca
wrote does, with the same reason and words.
…hats

A process whose chats another Orca owns, or whose chat journal will not
open, has no structured host. Its session-tabs inventory still answered,
with no chat rows, and the renderer read that as "every chat was closed":
it removed the restored chat tabs and the next session save persisted
their placement away.

The inventory now says `agentSessionsUnverifiable` when the last tab
restore ran with chats on disk but no host to list them. The flag is set
and cleared at the per-client projection point beside the client-hosted
page hold, and the restore is memoised only once a host answered, so a
later lock takeover or journal open republishes the chats and clears it.
The renderer keeps agent-session tabs, and keeps cancellation tombstones,
against an inventory that does not affirm its chat set.
…ss's way past it

A process refused because another Orca owns the profile's chats sent the
generic `journalUnavailable` reason, so current desktop and phone surfaces
said "couldn't open this chat's history right now. Try again." — a step
that never helps while the other Orca runs.

The refusal now names its own reason, `journalOwnedElsewhere`, with the
refused process's kind (dev desktop, packaged, orcad) as a fact. Each kind
gets its own step: quit the other Orca, or give this one its own profile
(ORCA_DEV_USER_DATA_PATH) or data folder (ORCA_USER_DATA). The sentences
are added to the shared notice copy, the desktop catalogs in all six
locales, and the boot catalog.

A client that predates the reason reads it as none and keeps the code's
words; an unknown kind reads as the packaged app's step. The `message`
released clients print is unchanged. Which requests refuse does not change.
…o ask

A refused startup kept its hostless result, so after the owner quit this
process never installed a host, never reconciled restart leases, and kept
telling clients it could not list its chats until a desktop chat request.
Taking the lock now reruns startup restoration once and pushes the chats.
The lock frees as its holder exits, but a refused process only learns that
on its next retry, and the 30 s cap left a second Orca refusing chats for up
to half a minute after the owner quit. One retry is an open and BEGIN
EXCLUSIVE on an empty file.
…ublish chats

A list that landed on the refused startup after the lock was taken finished
after the takeover had already checked, so nothing republished. The takeover
now installs first, waits for any restore in flight, and restores only then;
the restore that clears "cannot tell" pushes the frames itself, so a list
that heals the inventory first reaches subscribers too.
Quitting cancelled a refused claim's retry only at its end, so a retry firing
during the stop's awaits took the lock and installed a host the stop never
tore down, and the lock was then released under an open journal. The stop
now cancels the retry first, keeping the refusal, and repeats its teardown
while an install that began during it (a takeover already under way) is
pending, so no journal connection outlives the lock.
A refusal the host throws reaches the client as an RPC error whose message is
the bare code; its reason and facts ride only in the error's data, which no
client read. The chat pane's status line therefore printed
agent_session_journal_unreadable, a send took the bare "not sent" path, and
other writes said the outcome was unconfirmed.

One shared reader, agentSessionThrownRefusal, now reads the refusal from the
error data. A failed history read shows the refusal's read-history words, a
send keeps the refusal behind its Retry exactly as a returned refusal does, and
the other writes (desktop and phone) name the refusal instead of doubting the
outcome. The phone's read failure goes through the same reader.
Every chat request retries a journal open that failed, which is intended, but
each retry also logged the failure with its full stack: a junk database file
logged the same "file is not a database" error 189 times in a minute. The open
now logs a failure only when its code and message differ from the last one
logged, and forgets it once an open succeeds. The retry is unchanged.

The open moves to its own module beside the runtime, which had no room left.
…ies it on first use

Startup restore opened every restored chat, and that open copied the chat's
per-chat file into the host database, so the first boot after an upgrade paid
the whole one-time copy before the chat list appeared.

A restore open now reads a chat that is still in its per-chat file straight
from that file, read-only, with the importer's own reader, and closes the file
before moving on. That read drives the listing, the status row and the
restart offer, as it did when every chat had its own file. The copy becomes
owed work on the chat's write queue: it runs before the chat's first write,
and a reader that reaches the chat awaits it. A chat the host already holds,
or that was copied before, still opens through the import and its reimport
rules, and so does a file whose read needs a repair written.
…anned

Each teardown pass clears the registered host before it awaits an install in
flight, and that install registers its host when it finishes. The pass then
tore the host down but left it registered, so a request after the stop was
served by a host whose journal was closed. The stop now clears the slot once
its passes are done.
synchronous = FULL fsyncs each commit, but macOS fsync leaves the drive cache
unflushed, so FULL alone does not survive a power loss there. With
checkpoint_fullfsync, each checkpoint uses F_FULLFSYNC; elsewhere it is a no-op.
The comment that said FULL alone was enough is corrected.
An imported chat's per-chat file was kept under an `.imported-*` name, which
doubled the disk its history takes. The copy now reads back from the host
database before it is published: its items, submissions, epoch and tip must
match the file's. Only then does one transaction publish the chat with its
import marker, and the file and its WAL files are deleted, the directory too
when nothing else is in it (a pre-SQLite transcript there is kept).

A copy that does not match is never published: the file stays, the chat is
refused as unreadable ("Unable to load this chat."), and the mismatch is logged
once. A file left behind by a failed delete or a crash matches the marker, so
the next open deletes it rather than copying it again; a file an older build
wrote after a downgrade still differs, and is still copied again.
The check that a copied chat reads back as its per-chat file folded both whole,
each in one synchronous task: over half a second on the largest chat. Both
reads now go a batch at a time between turns of the event loop, like the copy
itself, and count rows as well, so a copy that lost a row with no item in it
is caught too.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new code issues in this delta — the two commits are green on tc:node, oxlint, and their focused tests (87 passing). One informational note on the description; no inline findings.

Reviewed changes

Re-reviewed the two commits pushed after 94182a8, which open a newer-Orca journal read-only and remove the saved listing-status machinery.

  • A newer-build journal opens read-only instead of being refused. openJournalDatabase now returns OpenJournalDatabase { db, readOnly } and, when user_version is above this build's, returns a readonly: true connection; JournalHostDatabase exposes that as readOnly, and restoreJournalStore diverts it to a new restoreFromNewerDatabase that folds each chat from the database (or its not-yet-copied per-chat file), adopts it read-only, and refuses every write as journal_read_only, so clients print the "Chats were saved by a newer Orca" message.
  • Saved listing status removed. The status_json/status_seq columns, journal-session-status.ts, the shared structured-agent-session-saved-status.ts, JournalListingStatusWriter/saveListingStatus, and the status feed's call to it are all deleted; journal_sessions is back to (session_id, workspace_id, epoch) and the schema stays at version 3.
  • Underlying cause plumbed through the refusal. AgentSessionJournalError now accepts ErrorOptions, and the read-only restore attaches the read failure as cause rather than flattening it.

ℹ️ The description still records the refused-newer-schema behavior this delta reverses

The body's "Differences from the common pattern" and "For a decision before merge" both state that a database stamped by a newer build is refused and left byte-identical; the new commits instead open it read-only and show its chats. Together with the still-stale owner-lock, block-key, listing-status and reclaim paragraphs, the description now documents several behaviors the code no longer has, and it is the only record of these decisions.

Technical details
# The PR description now contradicts the newer-schema and listing-status changes

## Affected sites
- PR body "Differences from the common pattern": "A database stamped by a newer build ... is refused and the file left byte-identical" — now opens read-only and serves its chats (`journal-store-restore.ts:82`).
- PR body "For a decision before merge": the rollback-rebuild item and "block-keyed rows" describe superseded layouts.
- PR body "Scope"/"What Changed": still list block-keyed rows, the listing-status column and bounded free-page reclaim, removed by `94182a8`/`f0c46576`.

## Required outcome
- Rewrite the newer-schema decision to say the database opens read-only with its history and refuses writes, and drop the block-key/listing-status/owner-lock text.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Brings in #23059 (turn facts from the turn record, /compact as a sent
message) and #23026 (Stop from the moment a message is sent), among 23
commits. Conflicts keep main's behaviour on the one shared journal
database: per-chat handle and close tests stay deleted, main's new host
tests open the shared database, and appended rows name their turn scope.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found — the merge is clean and verified.

Reviewed changes

Re-reviewed the merge commit pushed after f0c46576, which merges origin/main (d4530a8a) into the branch and reconciles main's turn-scope attribution with the shared journal.

  • Merged origin/main and adopted turn-scope attribution. Every journal write now states a turnScope; the branch's restore writes repair and file-format notices as AGENT_JOURNAL_THREAD_SCOPE, and epoch replacement derives one from position when the source states none.
  • Migrated main's host tests to the shared database dependency. The four host tests main added after the merge base now construct the host with journalDatabase: openTestJournalHostDatabase(root) instead of journalRoot, clearing the merge-ref typecheck failure raised in the previous review.
  • Pulled in main's cancel and command-turn paths. performCancel now lives in structured-agent-session-turns-cancel.ts and is re-exported from structured-agent-session-turns.ts; handOverSubmission routes a command body to handOverStructuredAgentSessionCommand.
  • Verified green. pnpm tc:node and pnpm tc:web exit 0; 33 focused vitest files / 319 tests pass over the journal and the migrated host tests; no conflict markers remain.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…ot to try again

When a newer Orca wrote the chat journal, this build opens it read-only. A send or a Stop was
refused with the reason `journalUnavailable`, so today's desktop and phone clients chose the
words for an open that can clear: "Orca couldn't open this chat's history right now. Try again."
Retrying never cleared it; only updating Orca does.

The refusal now names its own reason, `journalWrittenByNewerOrca`, whose words are "Chats were
saved by a newer Orca. Update Orca to keep using them." A read refused the same way names it
too. An older client does not know the reason, drops it, and falls back to the code's words
("Orca couldn't read this chat's saved history."), and released clients still print the message.
…usable, not as retryable

A chat journal database stamped with schema 1 or 2 was written only by unreleased development
builds of this change. Opening it threw a plain error, which every chat reported as "Orca couldn't
open this chat's history right now. Try again." Retrying never cleared it.

It now throws a named error that is classified as unusable, so every chat says "Unable to load
this chat." The one log line names the file, says an unreleased development build wrote it, and
says to move it aside. Nothing migrates or renames it.
…e comments

The chat-only owner lock is gone, so only a chat journal that will not open leaves a runtime
unable to list its chats.
…eft behind

A per-chat file left without its WAL is set aside, not copied again; nothing runs an incremental
vacuum yet, so the auto_vacuum mode is kept for a later pass; and the idle sweep drops a chat's
in-memory fold, since a chat holds no journal connection.
Each is used only inside its own module now; the teardown's export served a deleted test.
…eck every journal table

The test named 'migrates an older user_version forward' covers only a version-0 file that already
has its tables; versions 1 and 2 are refused. The table test now also checks journal_imports and
journal_set_aside.
…used a window

The running dev instance may be a background launch or a server, which show no window. The line
now says only that this launch passed its request to that instance.
… connection it opened

The install opened the chat journal database and closed it only if the record store then failed
to open. A later failure, such as the model catalog wiring or the host constructor, left the
connection open, and the next install opened a second one in the same process. Every failure
after the open now closes it.
Comment on lines +58 to 61
await ensureStructuredAgentSessionHostUnlessRefused(ensureHost)
const host = getStructuredAgentSessionHost()
if (!host) {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Resume bypasses ownership check

When this process cannot open the chat journal, the new refusal bypass lets a terminal run a target-less or matching claude --resume command without checking who owns the structured session. If another Orca already has that provider conversation active, the terminal can start a second provider writer against it. The resume needs an ownership verdict before it can be treated as safe.

Knowledge Base Used:

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in this delta — eight commits of targeted fixes, comment/export cleanup, and tests; no actionable findings.

Reviewed changes

Re-reviewed the eight commits pushed after a6a514fa, which split the newer-Orca journal into its own refusal reason, treat an unreleased-schema journal as unusable, close a leaked journal connection on a failed install, and tidy comments and exports.

  • A journal a newer Orca wrote gets its own refusal reason. journalWrittenByNewerOrca replaced the retryable journalUnavailable for a database stamped by a newer Orca or a chat holding newer rows, so a send or Stop now says to update Orca instead of to try again; the new copy lands in all six catalogs and both client surfaces.
  • A journal only an unreleased build wrote reads as unusable. openJournalDatabase throws JournalUnreleasedSchemaError for versions 1–2 and classifyJournalOpenFailure maps it to journalCorrupt, so those chats say "Unable to load this chat." while the once-logged line names the file to move aside.
  • A failed install closes the journal connection it opened. install() now wraps everything past the journal open, so a throw from the record store, environment resolvers or model-catalog wiring closes the one connection before rethrowing.
  • Comments and exports corrected. Wording left over from the removed owner lock and per-chat handle is gone; eight names nothing imports are no longer exported; the dev single-instance exit line no longer claims it focused a window.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Brings in the host-owned queue for mid-turn messages (#23726) and the /clear and
Codex rewind unlock (#23524), expressed on the one host journal database:

- Queue transactions run through runJournalTransaction on the shared connection,
  and every appended row keeps the draft hooks inside that same transaction.
- The open-time draft repair writes nothing for a chat with no draft, so a chat
  whose first-use copy is owed stays uncopied until its first real write.
- A newer Orca's journal takes no new draft, and Send-now, Delete and Resume on
  it refuse with the same update words a send gets.
- Main's queue tests open the shared database; a table-healing case restarts
  the host around its edit, since the table is ensured when the database opens.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Merging main's host-owned queued-message feature (#23726) into this branch leaves the migration from a per-chat journal.db lossy: that feature stores a chat's queued drafts and queue pauses in the same per-chat file the lazy importer copies and then deletes, and the copy takes only the journal rows. If any shipped or beta build carries #23726, a user's unsent queued messages are silently dropped on the first open after this upgrade.

Reviewed changes

Re-reviewed the merge of origin/main (29c49aec31) into the branch, which pulls main's host-owned queued-message feature and reconciles it with the one-database-per-host journal.

  • Queued-message writes routed through the shared connection. journal-queued-messages.ts writes through JournalHostDatabase.transaction, and journal-row-writer.ts runs main's draft-consume hook plus the SAVEPOINT-wrapped standing bookkeeping inside that same transaction, with the host database injected into the store instead of an owned per-chat connection (journal-store.ts).
  • Draft tables created at every writable open. openJournalDatabase calls ensureQueuedMessagesTable after createJournalSchema, deliberately without a user_version bump so a downgraded build stays writable (journal-database.ts:55).
  • Newer-Orca refusal extended to the queue. refusingNewerOrcaJournal wraps Send-now, Delete and Resume, maybeQueueStructuredAgentSessionSend short-circuits a read-only journal to the immediate path, and a new structured-agent-session-queued-newer-orca.test.ts covers the read-only queue.
  • Base merges. The other origin/main commits (unfinished /clear and Codex rewind recovery, browser viewport/crash fixes) are byte-identical to main and outside this PR's own diff.

⚠️ Queued drafts and queue pauses are dropped when a legacy per-chat journal is imported

A build containing #23726 stored a chat's queued drafts in queued_messages and its queue pause in queued_message_pauses, inside that chat's per-chat journal.db. The lazy importer copies only journal_rows and journal_repairs from that file, verifies, then deletes it — so the drafts and pause are discarded without an error, a notice, or a reset. Because the draft tables add no user_version bump, the file still passes the schema gate (LEGACY_JOURNAL_SCHEMA_VERSION = 2) and is retired.

Technical details
# Legacy queued drafts are not copied before the per-chat file is deleted

## Affected sites
- `src/main/native-chat/agent-session-journal/journal-per-session-import.ts:187-228` — `copyLegacyJournal` inserts only `journal_rows` (`INSERT_ROW`, `:58`) and `journal_repairs` (`UPSERT_REPAIR`, `:60`); no `queued_messages` / `queued_message_pauses` read or insert exists in the importer.
- `src/main/native-chat/agent-session-journal/journal-per-session-source.ts:18-25` — the legacy reader's SQL selects only `journal_sessions`, `journal_rows` and `journal_repairs`.
- `src/main/native-chat/agent-session-journal/journal-per-session-source.ts:14,35-37` — a schema-2 file (the shape #23726 left: `queued-message-schema.ts:15-22` documents no version bump) passes the gate.
- `src/main/native-chat/agent-session-journal/journal-per-session-import.ts:141` — `retireLegacyJournal(...)` removes `journal.db`, `-wal`, `-shm` and the directory after a successful copy, so the only record of the drafts is gone.
- No other mechanism recovers them: `journal-queued-messages.ts:91-107` reads drafts only from the shared database, and a waiting draft has no `journal_rows` row to re-derive from.
- The renderer drops its own outbox entry once the host answers `queued` (`src/shared/structured-agent-session-send-disposition.ts`), so the host draft table was the sole record.

## Required outcome
- If a build containing #23726 can have shipped, migrate a legacy file's `queued_messages` and `queued_message_pauses` into the shared database as part of the same publish, or refuse/set the file aside instead of deleting it. Add a test that seeds a legacy per-chat file with a queued draft and asserts it survives the import; no such test exists today.

## Open questions for the human
- Has a build containing #23726 (per-chat draft store) reached users? That decides whether this is field data loss or a nightly-only gap, and whether the migration is required before merge.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

caller,
envelope,
plan,
{ ...plan, run: (ctx) => refusingNewerOrcaJournal(plan.run(ctx)) },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refusingNewerOrcaJournal covers Send-now, Delete and Resume, but Stop withdraws queued submissions through mutateStructuredAgentSession (structured-agent-session-host-mutations.ts:186), not mutateQueued. On a read-only (newer-Orca) journal that write throws AgentSessionJournalError('journal_read_only'), which is neither an AgentSessionRefusalError nor in the RPC allowlists (src/main/runtime/rpc/errors.ts), so it surfaces as runtime_error/unconfirmed instead of the "update Orca" words. This is reachable when the read-only journal still holds a queued submission and a client or paired surface offers Stop.

Technical details
# Stop's queue withdrawal is not classified for a read-only journal

## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts:183-222` — Stop's plan runs through `mutateStructuredAgentSession` and calls `ctx.journal.rejectQueuedSubmissions(...)` at `:186`.
- `src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts:67-86` — `rejectJournalQueuedSubmissions` writes via `resolveDispatch` for each queued submission, with no read-only gate.
- `src/main/native-chat/agent-session-journal/journal-row-writer.ts:40` / `journal-write-guards.ts:23-30` — the write throws `AgentSessionJournalError('journal_read_only')`.
- `src/main/runtime/rpc/errors.ts:166-256` — `journal_read_only` is not an `AgentSessionRefusalError` and is absent from the passthrough sets, so it maps to `runtime_error`.

## Required outcome
- When a write meets a read-only journal on the Stop path, return the same `journalWrittenByNewerOrca` refusal the send path returns (`structured-agent-session-turns.ts:150-156`), or guard the call site with `ctx.journal.isReadOnly`. Worth a test mirroring `structured-agent-session-queued-newer-orca.test.ts` that also exercises `stop()`.

@brennanb2025
brennanb2025 merged commit 59b746f into main Sep 29, 2026
42 of 43 checks passed
@brennanb2025

brennanb2025 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Status: merged (head 8c0bd34)

The problem

Every structured chat kept its history in its own small SQLite file with its own connection. That per-chat handle lifecycle caused a run of bugs: handles leaking and blocking renames on Windows (#18652), quit closing files while cleanup still wrote (#18974), and ENOTEMPTY removing a chat directory with handles open (#18824). Separately, two dev builds on one profile both wrote every chat; measured on main, the second marked the first one's live send unknown and broke its next write.

What changes for you

  • Chat history on a profile lives in one file, opened once by the running Orca. The per-chat handle bugs above can no longer happen.
  • Existing chats keep their full history: each old chat is copied the first time it is used, checked row by row, and only then is its old file deleted. The first launch after upgrading is no slower than main; the first use of each old chat pays a one-time copy.
  • A second dev copy on the same profile now stops at launch, as packaged Orca and orcad already do, and says to set ORCA_DEV_USER_DATA_PATH. Running two dev builds side by side needs that variable for the second one.
  • A damaged journal no longer affects the rest of the app: tabs stay, terminals and saving work, and chats say "Unable to load this chat."
  • After a downgrade, chats open read-only with their history, and sending says to update Orca.

What the review changed

  • Matched the references: one process-level lock per profile in dev too, replacing a chat-only lock and its in-process takeover (which kept producing races); a plain (session_id, epoch, seq) row key instead of per-chat blocks; no after-every-delete free-page pass.
  • A newer-schema journal opens read-only with its history visible, with its own refusal reason so every client shows "update Orca".
  • The copy of an old chat is checked by content, not just counts, before the old file is deleted; a copy that stopped midway is invisible and cleaned up; a file an older build recreates is set aside and never overwrites history.
  • A damaged or unopenable journal never drops chat tabs and never puts startup into "Changes won't be saved".
  • Every write goes through one transaction helper, so a failed commit can't strand the shared connection, and a failed close keeps the connection until it really closes.
  • Moved out: the saved chat-list status (to the lazy-history PR, next to its reader).

Deferred (P3, in the review ledger)

  • Stop on a read-only chat with a message a newer Orca left queued says "couldn't confirm" instead of "update Orca".
  • After a downgrade, the "update Orca" send refusal still shows a Retry button that can't help.
  • In read-only mode, a damaged per-chat file says "update Orca" rather than "Unable to load this chat."
  • Rows from a half-finished copy stay in the file if the old per-chat file disappears before the next try; nothing reads them.
  • The copy check frames each row by string length rather than byte length (theoretical).

Known costs

  • The first use of each old chat after upgrading is slower than main, once (p99 361 ms against 91 ms on a 1,090-chat copy); no single main-thread task is longer than main's.
  • Rewinding the largest chat (68 MB) is faster than main when its rows were written in one go (231 ms against 476 ms), and slower in the worst case where they were interleaved with 19 busy chats (835 ms, about 1.75× main).
  • Profiles that ran a development build of this PR have a journal in an unreleased format; chats there say "Unable to load this chat." and the log says to move the file aside.

Verified

  • Review: a full review of the redesigned PR and two narrow reviews of later fixes and main merges, all with no P0 or P1.
  • CI: all 41 checks pass on 8c0bd34, including every unit test shard, static analysis and the typecheck. tc:node and tc:web pass locally.
  • Live QA on a separate Mac, real Claude chats, fresh profiles seeded by main: first boot after upgrading, a second dev launch, a damaged journal, a newer-schema journal, quit and relaunch, and an idle-stopped chat all pass; boot to first paint shows no measurable difference from main.
  • macOS measurements on the current layout; Windows on an earlier head (67f5e35adec, lazy copy but the older block row layout): first boot after upgrade faster than main, second boot and write latency at parity. A confirmation run on the current layout was stopped after the Windows test machine ran out of disk; the earlier-head numbers stand.

Not verified

  • Real Codex chats were tested on an earlier head, not the redesign; the storage paths they used (import, rewind) were re-covered by unit tests.
  • Fault-injection scenarios (crash mid-write, disk full, corrupt page) ran on an earlier head; the redesign did not change those storage paths except that two processes can no longer both open the journal.
  • The dev-lock change was not run on Windows or Linux desktops.

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