Skip to content

refactor(native-chat): keep agent-session records in the chat journal database - #24006

Merged
brennanb2025 merged 39 commits into
mainfrom
brennanb2025/chat-records-in-journal-db
Sep 30, 2026
Merged

brennanb2025 merged 39 commits into
mainfrom
brennanb2025/chat-records-in-journal-db

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes (scope)

Native (structured) Claude and Codex chats only. Terminal agents, orchestration terminals, chat history rows and the profile state are not touched.

The problem: each native chat has an ownership record (who owns the chat right now, which operations it already ran, which chat tabs were open). These lived in agent-sessions.json beside the chat database. Every change to any chat re-read and re-parsed the whole file, hashed it to detect other writers (which #23613's instance locks already rule out), took a cross-process file lock, and rewrote the whole file plus a .bak copy. That happened for a lease renewal every 10 s per live chat, for every send's ledger row, and for every tab show or hide. #23115 measured about 2.8 s of Windows startup CPU from this path on a 247-chat, 1,301-record profile. On top of that sat .bak fallback, salvage, a recovery fence latch and a stored "unusable records" list, each with its own failure modes. A store that could not be opened refused every chat until restart.

What changes for the user: each chat record write drops from about 34-40 ms to about 0.3-1.3 ms (measured on Mac and Windows), so chats that restore, renew or change tabs no longer pay a whole-file rewrite each time; steady-state startup is unchanged, and the first launch after updating pays a one-time copy (about 0.8 s on Windows). A records file that cannot be read no longer stops chats from working. A database written by a newer Orca opens read-only: history reads, the app starts, and sends, Stop, Delete and Resume say "update Orca". Stop still reaches the agent when the record of the Stop cannot be written.

Not affected: the RPC wire (no parameter or frame changes), chat history storage, SSH hosts (each host migrates its own database), folder workspaces (rows are keyed by session id).

This PR is step D of: R0 #23964 → L′-a #23978 → D0 #23986 → D (this). R0 #23964 and D0 #23986 are merged; this diff against main is D only.

Supersedes #23115.

ELI5

Orca kept a small notebook per profile listing every native chat's owner and recent actions, in its own file. Every time anything changed it re-read, re-checked and rewrote the whole notebook. This PR moves that notebook into the chat database Orca already has, as ordinary rows, and each change writes only the lines that changed.

What Changed

Before: every record change paid a whole-file read, parse, hash and rewrite; a records file the build could not open refused every chat until restart. After: each change writes only its own rows in one database transaction; nothing re-reads the records after launch; a records file that cannot be read never stops chats from working.

Mechanism:

  • agent-session-journal.db goes from user_version 3 to 4. Version 4 adds agent_session_records, agent_session_operations, agent_session_retired_claim_keys, agent_session_tabs and one meta row (session_tabs_recorded, which keeps "a tab index was never recorded" distinct from "recorded and now empty", as fix(native-chat): preserve structured chat across rollback #17439's legacy fallback needs).
  • The version-4 migration creates the tables, copies agent-sessions.json in, and sets user_version = 4 in one transaction. "Copied" is user_version >= 4; nothing else records it. A crash inside the migration leaves version 3 and no rows, and the next launch copies once.
  • The copy reads the file with main's existing loader. The JSON file and its .bak are never written, renamed or deleted.
  • A read that can clear (EACCES, EIO, EMFILE, on the primary or on .bak) leaves the copy owed: the tables are made, the version stays 3, the host installs, and the chat list says "can't say" rather than "none" until a later launch copies it. Chats created meanwhile keep their rows through that copy, and their tabs come back beside the profile's.
  • What the old hot path did that now happens only at import: the .bak fence raise, salvage, and set-aside handling. A readable record that also has a set-aside raw copy gets its fence floor raised above both (fix(agent-session): restore a quarantined session record this build can read #23589's case). A set-aside record with no readable copy is stored verbatim, so a build that can read it gets it back.
  • The store loads every row once at open. A record row this build cannot read is derived as unreadable at load (never stored as a list, never rewritten); writes to that chat are refused as before.
  • Every mutation drafts the published state, applies, checks each changed row with the same rules a load uses (perf(native-chat): the agent-session store stops re-reading its own writes #23115's row rules), writes only the changed rows in one journal transaction, then publishes the draft. A throw anywhere discards the draft. In tests and dev builds loaded and published rows are deep-frozen, so an in-place mutation the row diff would miss throws.
  • A small promise queue stays in front of every mutation, so a store call never runs BEGIN inside a caller's open journal transaction on the same connection.
  • The startup check "does this profile hold any chat" (which gates installing the host at startup) now opens the database read-only and looks for a record or tab row. While the import is owed the records file answers; a database it cannot read counts as present. The version probe no longer creates the database file.
  • hostId comes from the open (always 'local', which is also what every records file was created with). A file whose hostId differs is reported and imported; owners it names resolve unresolved against this host, the conservative verdict.
  • Deleted: the store's file lock, the external-change refresh and its revision hash, .bak copy-before-rename, salvage and the recovery fence on the ordinary path, the persisted unreadable list, needsRewrite, agent-session-store-transaction-queue.ts, agent-session-record-store-write.ts, agent-session-record-store-security.ts, agent-session-store-serialization.ts, and the unused setJournalCheckpoint (the persisted field stays).

Behaviour under failure:

  • Stop on damage (Intended). Stop reaches the agent even when the ledger row recording it cannot be written (SQLite damage, a full disk, a stranded transaction). The decision is made from the committed ledger in memory, the interrupt runs, and [agent-session] Stop's ledger row skipped is logged. A replayed Stop whose ledger row was written is still answered from memory and never interrupts twice; while record writes are failing, a repeat of the same Stop (same operation id) can interrupt the turn it names a second time, which is harmless for that turn and logged. This keeps main's contract (main pinned "Stop still reaches the agent on journal damage"), per the rule that bookkeeping never gates a user action; common designs refuse the interrupt when their write fails. Other actions on proven damage answer "Unable to load this chat."; a full disk or I/O error on those still throws, as on main.
  • Unusable records file (Intended). If both agent-sessions.json and its .bak can't be read, the older native chats are no longer listed, new chats work, the file is kept untouched and is not re-imported, and the failure is reported through the host's error reporting path (scope structured-agent-session-record-import). Today that path reaches only the log in production; the queued desktop host error sink change covers it. Common designs instead keep retrying a malformed source, either silently skipping it or blocking; we don't, because an owed import keeps the chat inventory at "can't say" every launch. A transient read error (primary or .bak) leaves the import owed and retried on the next launch.
  • Read-only on a newer database (Intended). A database a newer Orca wrote opens read-only, as feat(native-chat): one structured-chat journal database per host, owned by one process #23613 already does for chat history. The Orca-observed failure this prevents is fix(agent-session): restore a quarantined session record this build can read #23589: an older build acting on records it could not fully read set a record aside and the chat stayed gone. Startup reconciliation adjudicates leases in memory only (re-derived every start, grants no writer), so history reads and the app starts; every write gets the existing "saved by a newer Orca, update Orca" refusal, returned as a refusal result for send, Send-now, Delete and Resume. Opening a chat from Agent Session History works, read-only.
  • Tab-index write failure on create, reveal and close. The action succeeds and the tab opens or closes; the failed restore-index write is logged, and the tab may not come back after a restart. On create the result then carries no tabId.

Mixed versions and remote hosts: no RPC parameter or frame changes. One published refusal changes: on main a read-only record store threw agent_session_legacy_required, which an old client's remote-launch path treats as "fall back to a terminal launch". A read-only host here answers agent_session_journal_unreadable / journalWrittenByNewerOrca, which old clients already print as "update Orca", the same as a read-only journal does on main. No released build writes a newer JSON store, so the old path was unreachable in practice.

Downgrade, per build:

Deleted cross-version tests: tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts and agent-session-unproven-release-downgrade.unit.test.ts, and the .github/workflows/pr.yml line that ran the second one. Both had a v1.4.211 release checkout read a JSON store this build wrote. This build never writes agent-sessions.json, so an older build only ever reads a file it or an earlier build wrote; the import is pinned instead by byte-identity tests (the file and .bak are unchanged after import).

Test cleanup fix: restart tests replaced the test host without waiting for the old host's background restart-offer withdrawal, which held the recovery capsule's lock directory while cleanup removed the test directory (ENOTEMPTY). The harness now waits for those operations before removing it, replacing the earlier rm retries. Production quit already awaits them (its teardown drains that lane).

Size

D-only, against main (merge base ba39c6d5fd6; heuristic: a path containing test/e2e/harness/fixture counts as test):

Files Added Deleted
Production 31 1,206 554
Tests 37 1,866 1,773

The design estimated about +560 / -340 production. The actual is larger because of:

  • 241 of the 1,206 added production lines are comments (182) or blank lines (59);
  • retargeting the store's whole API onto the database (open, transactions, draft, row rules, row reader and writer);
  • the tab-index writer and its "never recorded" meta row, plus restoring tab rows a chat left while the import was owed;
  • load branches (unreadable rows derived at load, a read-only database without the record tables);
  • the Stop fallback and the typed refusal on damage;
  • the startup probe that answers from database rows.

Cuts made during review: the unused in-transaction write hook was deleted, and the provider-handle chain-validation refactor was reverted to main's version.

Why

Records are coupled to journal rows (the operation ledger to submission rows, rewind records to journal replaces, conversation commands to their rows), so one database with one version latch is the right home: an older build becomes read-only on both at once, through a latch that already shipped. Keeping the JSON file but tuning it (#23115's approach: fingerprint the input to skip the re-parse, validate rows at write) keeps the lock, the .bak, salvage and the recovery fence, all of which exist only because the file is a second store with its own failure model. This removes that model instead of making it cheaper. #23115's row rules and draft-then-adopt model are reused.

Differences from the common pattern

The common pattern: session runtime state as rows in the app's one SQLite database, written per row, with the next in-memory model adopted after commit, numbered migrations run at startup, and a failed orphan reconcile logged while startup carries on. D matches that on where records live, per-row writes validated with the row schema, draft-then-adopt, a numbered migration with the copy inside it, single-writer serialization, and reconcile failures that never stop startup.

  • Matches: records are rows in the one per-host database, beside the chat history they describe.
  • Intended: one JSON value per row, not typed columns. A row an older build cannot read stays byte-identical until a build that can read it returns. The Orca-observed failure is STA-7446 / fix(agent-session): restore a quarantined session record this build can read #23589 (an older build set a newer record aside and the chat never came back); typed columns would need a migration per field and still need the JSON validator.
  • Intended: all rows loaded once and kept in memory, not read per call. 29 production files and L′-a use the store's synchronous getters, and the single-writer premise is feat(native-chat): one structured-chat journal database per host, owned by one process #23613's instance lock.
  • Intended: a row this build cannot read refuses writes to that one chat instead of being skipped. Skipping would let this build grant a writer for a chat whose real state it cannot read; refusing is main's existing rule (agent-session-reservation-admission.ts).
  • Intended: a newer-schema database opens read-only and never migrates down. See "Read-only on a newer database" above (fix(agent-session): restore a quarantined session record this build can read #23589).
  • Intended: the source file is never touched after import. An older build still reading its own file is the downgrade path, and fix(native-chat): preserve structured chat across rollback #17439 showed an older build rewriting shared state drops chat tabs.
  • Intended: an unusable source imports nothing and does not block. See "Unusable records file" above.
  • Intended: Stop runs without its ledger row when that write fails. See "Stop on damage" above.
  • Intended: frozen rows in tests and dev builds. Rows are diffed by identity, so an in-place mutation would silently not persist; freezing makes it a loud failure where it is cheap. Packaged builds skip it.
  • Intended: the session_tabs_recorded meta row. fix(native-chat): preserve structured chat across rollback #17439's legacy profile fallback needs "never recorded", which no row count can express.
  • Temporary: record changes and the journal rows they belong with are written in separate transactions. The common pattern commits an event and its projection together. Follow-up F1 writes record and journal changes in one transaction, which is now possible because both live in one database.
  • Temporary, pending an investigation: per-chat leases with fences and process probes, reconciled by writes at startup. The common pattern has no per-chat lease: either everything not started by the current process is treated as orphaned and settled from its stored row, or leases are held per host session rather than per chat. A follow-up investigates whether Orca can take the first shape (stop orphans by recorded pid and start time, settle from the stored row) and delete per-chat leases, fences and the restart-reconcile writes. This PR keeps today's lease model unchanged.
  • Temporary: the recovery capsule still uses its own file lock. Follow-up F2 moves it.
  • Temporary: the importer and its legacy handling (.bak fallback, salvage, fence raises, and the load-time legacy terminal-handoff lease mapping and latch drop). Follow-up F3 retires the importer and moves that normalization out of the load path with it.

Deviations from the design (DESIGN-v2)

  • Fence floor for a readable record beside a set-aside copy: the set-aside copy adds its next fence without the design's +1. The design's own expected result ("the next grant is 8" for copies at fence 2 and 7) needs this, and it is the rule fix(agent-session): restore a quarantined session record this build can read #23589 shipped. The readable copy's +1 (the backup-recovery rule) is kept.
  • Tab-index failure: the UI tab close, reveal and create report a failed index write and carry on. The agentSession.close RPC still refuses when the index write fails, because an existing test pins that on purpose (does not stop the provider when durable tab retirement fails, from feat(mobile): structured native Codex chat #18074).
  • The migration's open is split in two: a sync openWith(stateDirectory, legacyRecords) beside the async open(stateDirectory, readLegacyRecords), so the 88 synchronous test callers of the test database registry keep working.
  • A records file written by a newer JSON schema (none was ever released) is imported like any other readable file, rather than treated as unusable.
  • A read-only database that somehow lacks the record tables loads as an empty store instead of failing install (only possible if a future schema were not additive).
  • A store refusal at mutation admission now comes back as a refusal result instead of a thrown error. Needed once the store and journal share a database: without it a newer Orca's database made Send-now, Delete and Resume throw.

Known costs

  • The first launch after updating pays the one-time copy: about 0.8 s of database open on Windows (1,300 records, 492 MB database) and about 45 ms on a Mac, during which the renderer waits about +0.2 s (Windows) for the chat host once. Later launches do not pay it.
  • Chats created by a pre-feat(native-chat): one structured-chat journal database per host, owned by one process #23613 stable build during a downgrade (records only in its JSON) do not reappear after upgrading again: the import runs once. Their per-chat history files remain.
  • The records and the chat history now share a database, so SQLite damage that reaches the record pages refuses record writes too (Stop excepted, see above); before, the JSON store failed on its own terms.
  • The startup presence check now opens the database read-only when it exists (about 1-2 ms measured standalone in review, independent of database size). A profile without chats no longer installs the host at startup.

Linked Issue

None. Planned refactor; supersedes #23115.

Visual Proof

Live QA on a separate Mac (M4 Air), background launches with isolated profiles, of this PR at 895b256c1bc against main at fceca5cecec (which has #23613). Later commits are a comment, main merges and a test fix.

Upgrade: chats made on main come back after the one-time copy (database version 3 → 4, agent-sessions.json byte-identical):

Base build, profile up: chat A active with CHAT ONE; tabs Terminal 1, A, B

PR build on the upgraded profile: same tabs restored, A active with its history

PR build: chat C, whose tab was closed on base, reopened from Agent Session History with its history

Quit and relaunch: a tab rename and a chat made on this build persist:

PR build after quit and relaunch: rename and chat D persist, D active

Stop mid-turn, then send again:

PR build: long list stopped at 134, tab Interrupted, no cancellation note shown

PR build: AFTER STOP answered after the stopped turn

A database written by a newer Orca: send says "update Orca", the tab still closes, and history opens read-only:

PR build on user_version 5: send refused with Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them. and Retry

PR build on user_version 5: C's tab closed with no message shown

PR build on user_version 5: chat C reopened from Agent Session History, history reads

Downgrade to main's build on the upgraded profile, then back:

Base build on the PR-upgraded profile: tabs A and Renamed B QA, B history reads

Base build on version 4: send refused with the same newer Orca wording

PR build after the downgrade run: four chat tabs, D with step-3 history intact

Testing

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

Measurement: main + R0 (base) vs D, 7 paired and interleaved launches per set on each machine (6 for the Mac first launch), with a seeded profile of 1,300 records, 260 operations, 180 chat tabs, a 2.1 MB records file and a 492 MB chat database. Isolated profiles, background launches, production bundles. Windows low spec was not used (out of disk).

Metric (paired D − base median) Mac Windows high spec
Record write p50, tab visibility (base → D) 39.9 → 0.51 ms 33.9 → 1.34 ms
Record write p99, tab visibility (base → D) 94.9 → 8.4 ms 45.6 → 2.5 ms
Record write p50 / p99, conversation name (base → D) 39.9 → 0.26 / 91.2 → 2.8 ms 34.3 → 1.17 / 40.3 → 1.9 ms
Startup to first paint, steady +3 ms (noise) +33 ms (noise; ready-to-show was bimodal on that run, did-finish-load −1 ms)
session.tabs.listAll at launch / on demand +27 / +0.1 ms (noise) −34 / −0.02 ms (noise)
Record-store CPU at startup, steady +0.1 ms +5 ms
Record-store open, steady (wall) — 901 → 13 ms (not on the renderer's path)
Startup lease reconcile, steady −28 ms −12 ms
First launch only: database open incl. the one-time copy +44 ms +811 ms
First launch only: record-store CPU incl. copy +38 ms +281 ms
Quit time +1 ms −17 ms
Database size +2.76 MB +2.76 MB

#23115 measured about 2.8 s of Windows startup CPU on a 247-chat profile. That came from restoring many chats, each paying a whole-file re-read per transaction. This seed restores no chat panes (no repositories), so it does not reproduce that number; it measures the per-transaction cost behind it directly: about 34 ms per record write on main + R0 on Windows versus 1.2-1.3 ms here. Side observation, not caused by this PR (no tray code changes): 3 Mac first launches of this build, each right after a main-build launch had quit, hung the main process in macOS menu-bar tray drawing. It did not reproduce in 10 back-to-back launches of this build alone.

QA (screenshots above): upgrade/copy, quit/relaunch, send/Stop, a newer-Orca database and a downgrade to main's build all behave as described. Found along the way, all already on main and not changed here: a message refused with "update Orca" is sent automatically on a later launch (follow-up owned separately); Claude chats show no "Cancellation requested." note and have no Rewind control.

New and changed tests (each run red with the mechanism it covers removed, unless it is a regression pin):

  • journal-database-records-migration.test.ts: v3 → v4 with rows and copy; never copies twice; a crash inside the migration leaves v3 and no record tables, then copies once; an owed copy makes the tables and keeps v3; a pinned copy of the version-3 open latches a v4 database read-only; the version probe never creates the file.
  • agent-session-legacy-record-import.test.ts: every record, operation, key and tab copied, JSON and .bak byte-identical; copies once; unusable file reported, nothing copied, file untouched; host-id mismatch; legacy terminal-handoff lease mapped; set-aside record stored verbatim and refused; both-lists floor (fence 2 beside 7 → next grant 8); salvage from .bak; torn primary with a transient .bak read error stays owed; an unreadable primary never falls back to a valid .bak (pin); a file with no tab index leaves the index unrecorded beside a chat created while owed; tab index seeding.
  • structured-agent-session-startup-tab-restore.test.ts: a chat opened while the copy was owed comes back, with its tab id, beside the profile's chats.
  • structured-agent-session-record-import-install.test.ts: an unreadable file → host installs, one report, v3, inventory "can't say", next launch copies; an unusable file → host installs, one report, v4, file untouched.
  • agent-session-store-transactions.test.ts: one renewal of three chats changes one row; a no-op writes nothing; a store call inside an open journal transaction runs after it; invalid rows reject their own transaction with memory and rows unchanged; in-place mutation throws; a write after close rejects with journal_closed.
  • structured-agent-session-journal-corruption.test.ts: main's whole-database damage test restored (send refused "Unable to load this chat.", Stop reaches the agent once, then rejects on the note it cannot record); record-only damage and a full disk (Stop resolves cancelled: true, interrupt sent once, skip logged); a replayed Stop answered from memory without a second interrupt.
  • structured-agent-session-runtime.test.ts: the startup presence check on an empty database, a database with a record, an owed database with and without the records file, and an unreadable database.
  • structured-chat-tab-table.test.ts: opening a chat from history over a newer Orca's records; a create whose index write fails still opens the chat, logs, and returns no tabId.
  • structured-agent-session-startup-reconcile-failure.test.ts: R0's startup tests now inject write failures at the row writer; a failure sink that throws does not fail the startup read.
  • Host test harness: structured-agent-session-host.test.ts ran 10 times with 0 ENOTEMPTY (with the wait removed, 2 of 5 runs failed).

Ran locally, explicit files only: the affected-test batch (175 files, 1,364 tests), the record-store and host suites (32 files, 393 tests at the head), and the cross-version wire suite (all 9 files pass; under machine load two timed out on release-checkout setup and passed alone or with a longer timeout). tc:node through the typecheck gate: clean. oxlint on every changed file and check:code-quality:changed: clean. Mobile tests were not run locally; they ran in CI (the Mobile Checks workflow passed at add94320eda), as did the full desktop unit suite, the cross-version wire suite and packaging.

Review

Agent skill upstream boundary

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

Notes

Security: the database files are hardened as #23613 already does; the records file's own permission hardening is gone with its writes. Cross-platform: no path or shell changes; the chmod 000 test skips on Windows and as root, and the directory-at-path tests run everywhere. SSH: each host migrates its own database; the presence check reads the local profile's database only. Mobile: unchanged wire. Backwards compatibility: see Downgrade above. Performance: see Measurement under Testing.

Checklist

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

…ailing app startup

At startup the chat host re-checks every saved chat's lease and writes the
result to agent-sessions.json. If that write failed (the file lock gave up,
the file could not be written, or the file was written by a newer Orca and
is read-only here), reconcileRestartLeases rejected, the startup IPC call
rejected, and the renderer fell into its degraded "Session restore failed.
Changes won't be saved until restart" mode.

The reconcile is bookkeeping: a lease left unreconciled grants no writer,
and every attach, send and read of a chat reconciles its own lease again.
So the startup reconcile now reports its failure through a new optional
host dependency, onStartupReconcileFailure, and resolves. The runtime
routes it to its onError sink under the scope
structured-agent-session-startup-reconcile, or logs it when no sink is
installed (the desktop installs none).
…eping

With native chat on and a chat tab open at quit, the renderer's startup
also awaits the chat tab restore (session.tabs.listAll). That restore
re-ran the lease reconcile before reading each chat and rethrew its store
failure, then recorded each restored tab as visible through a store
transaction that throws on a held lock or a read-only store. Either one
failed the restore, so startup still fell into "Session restore failed".

Reading a chat grants no writer, so the reconcile startup and the restore
run is now a reader's: createReaderReconcile never throws, answers whether
every lease is settled (recovery is resolved only then; the journal opens
either way), and reports each distinct failure once until a reconcile
settles. Attach and agent start keep the strict reconcile. The restore's
tab republish logs a failed visibility write and still publishes the tab,
since a client drops every unpublished chat tab; user-driven publishes
still refuse.

The host dependency is renamed onLeaseReconcileFailure (scope
structured-agent-session-lease-reconcile), since it now also reports for
reads.
…read path

Round-2 review found two more writes on the startup chat restore that
could still fail it and put the app into "Session restore failed":
republishing a /clear replacement recorded its tab visibility strictly,
and resolving a chat's recovery rethrew its store error. The restore
also paid one lock wait per tab and per batch of chats while the lock
stayed held.

The restore now derives tabs from state it already holds:
- publishStructuredAgentSessionTab splits into the strict write and
  projectStructuredAgentSessionTab, which only updates the runtime's
  snapshot. The restore and /clear replacements only project: a saved
  tab index already lists every restored chat, and a /clear moves the
  tab in the same write that commits it. visibilityWriteMayFail is gone.
- Chats a legacy profile restores that the index does not list are
  recorded in one best-effort transaction (store.showSessionTabs), so a
  failure leaves the index absent to seed again rather than partial.
- The read restore's recovery resolution is caught and reported through
  onLeaseReconcileFailure, deduplicated with the reconcile's reports.
- Once lease bookkeeping fails in a restore pass, the rest of that pass
  skips it, so a held lock costs one wait for the startup reconcile and
  one for the restore, however many chats are open.

User actions (create, reveal, attach, send, the /clear commit) keep
their strict writes.
…harness

Tests that open the durable agent-session record store, seed it, or read
back what it persisted now go through agent-session-record-store-test-harness.ts
instead of calling AgentSessionRecordStore.open or touching agent-sessions.json
themselves. A later change that moves the store into the chat database then
changes the harness instead of every test. No production code changes.

Tests whose subject is the JSON file itself (its .bak recovery, salvage,
schema versions, permissions, and what older builds read back) keep reading
and writing the file directly; the storage move rewrites or deletes them.
…op its bookkeeping at the first failure

The restore now runs one reader lease check for the pass and lets each chat
re-check and resolve recovery only while the pass is still settled. The
first refusal or failed write clears it for the rest of the pass, and every
chat is still opened for reading. With another process holding the lock,
startup waits on it once in prepare and once in the restore, however many
chats are open; a legacy profile waits once more for its tab-index seed.
…artup' into brennanb2025/chat-records-in-journal-db
The harness took the store's own folder, so each caller picked one
(join(root, 'store'), or 'agent-sessions' where a test read the store the
runtime owns). A later change that moves the store into the state
directory's journal database could not tell those apart, and would have
had to edit every caller again.

Every harness function now takes the state directory, the one the test's
journal database and recovery capsule already live in, and keeps the
store in the same subfolder the runtime uses. Callers pass that directory;
store-only tests pass their temp directory unchanged. Format tests that
share a directory with harness calls take the file path from
testAgentSessionStoreFilePath.

The folder name moves from a private constant in the runtime to
AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness
shares it without importing the runtime. Its value and every path built
from it are unchanged.
… database

The record store's records, operation ledger, retired claim keys and chat tab
index become tables in agent-session-journal.db (user_version 4). The version-4
migration copies agent-sessions.json in its own transaction and never writes,
renames or deletes that file or its .bak. Each store write is one journal
transaction over exactly the rows it changed, checked with the load rules; the
file lock, the external-change refresh and its hash, the .bak rotation, salvage
and the hot-path recovery fence are gone from the store.
…elper' into brennanb2025/chat-records-in-journal-db

# Conflicts:
#	src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts
#	src/main/runtime/agent-session-record-store-tab-table.test.ts
#	src/main/runtime/agent-session-record-store-test-harness.ts
#	src/main/runtime/agent-session-record-store.test.ts
#	src/main/runtime/rpc/methods/agent-launch-replay.test.ts
#	src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts
#	src/main/runtime/structured-agent-session-rollback-compatibility.test.ts
#	src/main/runtime/structured-agent-session-runtime.ts
…artup' into brennanb2025/chat-records-in-journal-db

# Conflicts:
#	src/main/runtime/structured-agent-session-startup-tab-restore.test.ts
…ords-in-journal-db

# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts
#	src/main/runtime/agent-session-record-store-file.ts
#	src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts
#	src/main/runtime/agent-session-record-store-tab-table.test.ts
#	src/main/runtime/agent-session-record-store-test-harness.ts
#	src/main/runtime/agent-session-record-store.test.ts
#	src/main/runtime/rpc/methods/agent-launch-replay.test.ts
#	src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts
#	src/main/runtime/structured-agent-session-integration.test.ts
#	src/main/runtime/structured-agent-session-rollback-compatibility.test.ts
#	src/main/runtime/structured-agent-session-runtime.ts
Brings in main at 75040eb. main's record-store test harness moved the
host test harness's store from <root>/store to <root>/agent-sessions, so the
startup reconcile failure test now opens, edits and reads the relaunched
store through agent-session-record-store-test-harness instead of the old
path.
… be written

Stop's operation-ledger row now shares the database with the chat history, so
damage, a full disk or a stranded transaction on that write refused the Stop
before the interrupt. A cancel plan now takes its decision from the committed
ledger in memory, runs without settling, and warns that the row was skipped.
Other mutations answer proven damage with the typed "Unable to load this chat."
refusal instead of the raw SQLite error.
…ase's rows

Every host install creates agent-session-journal.db, chats or not, and the
version probe created it too, so its mere existence made every profile that
ever installed the host wait on host install and reconcile at startup. The
check now opens the database read-only and looks for a record or tab row,
lets the records file answer while its import is still owed, and counts an
unreadable database as present. The version probe no longer creates the file.
…be written

Over records a newer Orca wrote, every write is refused, so opening a closed
chat from Agent Session History failed on the tab-visibility write and the chat
read as unreachable. Like closing a tab, opening one now reports a failed
restore-index write and still publishes the tab.
…ails transiently

A torn records file whose .bak could not be read (EACCES, EIO) was reported as
unusable, so the migration completed with nothing copied and never retried.
A non-ENOENT read failure of either copy now carries its cause, which the
importer classifies as a read that can clear.
…had no tab index

A chat created while the import was owed recorded a tab index holding only
itself. When the file it later imported had no index, that index still read
as recorded, so the imported chats' tabs never came back. The import now
clears the recorded marker in that case, and restore falls back to the
profile's tabs.
Nothing called it, and it bypassed the write queue and the read-only refusal.
…as it

The chain-validation refactor has no measured need in this change.
@brennanb2025
brennanb2025 changed the base branch from brennanb2025/record-store-test-helper to main September 30, 2026 07:14
…ide the profile's chats

When the imported records file had no tab index, restore fell back to the
profile's saved tabs, which never list a Claude chat, and the seed then
rewrote the tab table without the chat opened while the import was owed.
The tab rows that chat left are now loaded as unrecorded, restore takes them
together with the profile's chats, and the seed keeps their tab ids.
…ords-in-journal-db

# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts
#	src/main/runtime/agent-session-record-store.ts
#	src/main/runtime/agent-session-tab-table.ts
#	src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts
#	src/main/runtime/structured-agent-session-startup-reconcile.test.ts
#	src/main/runtime/structured-agent-session-startup-tab-restore.test.ts
#	src/main/runtime/structured-agent-session-tab-index-seed.ts
…e in the Send-now rerun test

The record store freezes published rows in tests, so setting a row's outcome
in place threw; the test now swaps in a changed copy, as its sibling cases do.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for review

The problem. Native (structured) Claude and Codex chats keep an ownership record per chat (who owns it, which operations ran, which tabs were open). On main these live in agent-sessions.json, and every change re-reads, re-parses, hashes and rewrites the whole file under a file lock, plus a .bak copy. Each record write costs about 34-40 ms, and a records file that can't be opened refuses every chat until restart.

What changes for the user. Records move into tables in the chat database Orca already has, and each change writes only its own rows. Measured on a Mac and on a Windows high-spec machine, each record write drops to about 0.3-1.3 ms. Steady-state startup is unchanged. The first launch after updating pays a one-time copy of about 0.8 s on Windows and about 45 ms on a Mac. A database written by a newer Orca opens read-only: history reads, and sends say "update Orca". Stop still reaches the agent when its bookkeeping write fails.

Fixed during review

  • Stop no longer depends on writing its bookkeeping row. Main's "Stop still reaches the agent on database damage" test is restored, and other actions on proven damage answer "Unable to load this chat." again instead of a raw database error.
  • At startup the chat host is installed only when the database actually holds chats; any profile that ever touched the host used to pay that cost. The version check no longer creates the database file.
  • On a database from a newer Orca, opening a chat from Agent Session History now works read-only. Before, it failed as "unreachable".
  • The one-time copy of the records file:
    • A transient read error on the records file's .bak leaves the copy owed and retried on the next launch, rather than counting as permanent.
    • Tabs come back after a copy that was delayed by a read error, both for imported chats and for chats opened in the meantime.
  • Cleanup and scope:
    • Removed an unused in-transaction write hook.
    • Reverted an unneeded refactor of the provider-handle chain check to main's version.
    • Fixed stale comments.
  • Tests and CI:
    • Replaced a test-cleanup retry with the actual fix: a replaced test host's background restart-offer writes are now awaited. Production quit already awaited them.
    • Fixed the static-analysis failure that had kept the unit-test job from running.
    • Fixed a main test that edited a stored row in place.

Deferred (low severity, not fixed here)

  • While record writes are failing, a repeat of the same Stop can interrupt its turn a second time. This is harmless for that turn and logged.
  • A records file that can never be read (both copies damaged) hides the older native chats. New chats work, the file is kept untouched and is not re-imported, and the failure goes to the host's error reporting path. This is stated in the body as intended.
  • A permanently unreadable .bak, or one record whose fence can't be raised, are edge cases that need a corrupt or hand-edited file.
  • Moving legacy lease normalization out of the load path waits for the importer's retirement.
  • Two follow-ups are stated in the body as temporary: writing records and journal rows in one transaction, and investigating whether per-chat leases can be replaced by orphan settlement from stored rows.

Already on main, found during QA, not changed here

  • A message refused with "update Orca" is sent automatically on a later launch, because the outbox hold is kept only in memory. A follow-up PR is owned separately.
  • Claude chats show no "Cancellation requested." note and have no Rewind control.

Verified

  • Typecheck (tc:node) is clean.
  • Targeted and affected tests pass locally: 287 agent-session and structured-chat test files, 2,420 tests.
  • CI is green on add94320eda: the full desktop unit suite, Mobile Checks, the cross-version wire suite, static analysis, and packaging. The Windows packaging job needed two reruns for two different timeouts in tests this PR does not touch.
  • Readiness checklist on the final diff found no P0/P1 items.
  • Paired measurements on Mac and Windows high spec, in the body.
  • Live QA on a separate Mac, screenshots embedded in the body: upgrade and copy with the source file byte-identical, quit and relaunch, send and Stop, a newer-Orca database, and a downgrade to main's build and back.

Not verified

  • perf(native-chat): the agent-session store stops re-reading its own writes #23115's exact 2.8 s figure. The seed had no repositories, so no chat panes restored at startup; the per-write cost behind that figure was measured instead.
  • Windows low spec, which is out of disk.
  • Rewind and chat delete, which have no UI control for Claude chats on main.
  • A live Stop on damaged storage, which is covered by tests only.
  • Three Mac first launches of this build hung in macOS menu-bar tray drawing, each right after a main-build launch had quit. It did not reproduce in 10 launches of this build alone, and this PR changes no tray code.

@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Critical risk] Migrates chat records into the journal database schema.

The PR should not merge until a temporary backup-read failure cannot permanently strand a recoverable chat.

Summary

Moves native-chat ownership records, operations, claim keys, and tab visibility from a JSON file into version-4 chat-journal tables, with a one-time import and per-row transactions.

  • Preserves the legacy file for older builds and opens newer-schema databases read-only.
  • One recoverable backup-read failure during import can permanently prevent salvage of an unreadable chat row.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Legacy records file] --> B[Read and salvage]
  B --> C{Import owed?}
  C -->|Yes| D[Keep version 3 and retry]
  C -->|No| E[Copy rows and set version 4]
  E --> F[Load records from journal database]
Loading

Reviews (1) · Last reviewed commit: "test(native-chat): replace a ledger row ..."

@greptile-apps

greptile-apps Bot commented Sep 30, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Temporary backup failure strands chats src/main/runtime/agent-session-record-store-file.ts:235 ▶

    If the primary records file contains an unreadable chat row and reading .bak fails temporarily, this catch skips recovery from the backup. The importer then copies the unreadable row and marks the one-time migration complete. Even if the backup becomes readable on the next launch, it is never checked again, so the chat remains unavailable. Keep the import pending when a needed backup cannot be read.

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Repository guideline files applied to this review (1)
AGENTS.md — auto-discovered
📝 Walkthrough

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to add94

This moves native chat records into the chat database, and startup imports the old records file. One narrow edge case remains. A set-aside chat record with a malformed field can leave the fence floor too low, so a later grant could reuse a fence that record may still hold. This is mergeable after that small fix or with owner awareness.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to add94

The change affects durable chat ownership and recovery. Migration is protected against partial completion, but a Stop completed during a storage failure can lose replay protection and affect later work. Recovery and caller-access behavior were only partially verified.

Retained concerns

  • Medium · reliability · inferred: A Stop that succeeds after its operation-row write fails leaves no durable replay decision. Repeating the same unnamed operation can therefore execute again, including after storage recovery and a later turn starts. This weakens operation isolation and failure containment: a retry intended to confirm an earlier Stop can interrupt later work. Session serialization, existing-row replay, and named-turn checks constrain the impact but do not preserve the original target of an unnamed ledger-free Stop.
Security review details

Security Blast Radius

  • inferred — Moving ownership and operation state into the journal couples their persistence failure domain to chat history. Individual cancellation execution remains targeted to one envelope session and its runtime fence; the demonstrated replay concern does not itself establish cross-session or cross-profile execution.

Security Findings and Attack Paths

  • inferred — The supported adverse sequence is partial record-storage failure, successful ledger-free Stop, subsequent work, and repetition of the same unnamed operation. This is an operation-replay and failure-containment concern, not a verified unauthenticated attack path. Authorization at the outer transport boundary remains incompletely traced.

Trust Boundaries and Controls

  • observed — Ledger-free execution still requires an attached journal, an existing session record, host-computed fingerprint agreement, and the shared admission decision. Ordinary conversation Stops explicitly bypass writer-lease admission in the head policy; prompt and background-task cancellation retain that check. This fallback is not an unconditional admission bypass.

Resilience and Maintainability Implications

  • observed — Existing durable operation rows still produce replay decisions instead of repeating cancellation, and named-turn cancellation passes the requested turn and a live-turn resolver to the provider adapter. These controls limit stale cancellation but do not replace replay identity for an unnamed Stop whose operation row was never committed.

Hardening Proposals

  • proposed — Preserve the operation identity and original target generation for successful emergency Stops, and define how stale retries are handled after storage recovery or restart. Any fallback replay protection should avoid allowing the same request to target later work.
🚥 Pre-merge checks | ✅ 4 | ❓ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ❓ Inconclusive Docstring coverage is 50.54% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 93 functions across 50 files. (4 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: moving native-chat agent-session records into the chat journal database.
Description check ✅ Passed The description is detailed and covers the required change, rationale, testing, visual proof, compatibility, security, performance, and known limitations. The linked issue section says "None," and the…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.54% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 93 functions across 50 files. (4 skipped: 4 over the file limit.)

✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 598df067-c69e-4693-96bf-63081166f5a3

📥 Commits

Reviewing files that changed from the base of the PR and between ba39c6d and add9432.

📒 Files selected for processing (68)
  • .github/workflows/pr.yml
  • src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts
  • src/main/native-chat/agent-session-journal/journal-database-schema.ts
  • src/main/native-chat/agent-session-journal/journal-database.test.ts
  • src/main/native-chat/agent-session-journal/journal-database.ts
  • src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts
  • src/main/native-chat/agent-session-journal/journal-host-database.ts
  • src/main/native-chat/agent-session-journal/journal-open-failure.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts
  • src/main/runtime/agent-session-backup-recovery-fence.ts
  • src/main/runtime/agent-session-backup-recovery.test.ts
  • src/main/runtime/agent-session-lease-transitions.ts
  • src/main/runtime/agent-session-legacy-record-import.test.ts
  • src/main/runtime/agent-session-legacy-record-import.ts
  • src/main/runtime/agent-session-record-rows.ts
  • src/main/runtime/agent-session-record-store-file.ts
  • src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts
  • src/main/runtime/agent-session-record-store-security.test.ts
  • src/main/runtime/agent-session-record-store-security.ts
  • src/main/runtime/agent-session-record-store-tab-table.test.ts
  • src/main/runtime/agent-session-record-store-test-harness.ts
  • src/main/runtime/agent-session-record-store-write.ts
  • src/main/runtime/agent-session-record-store.test.ts
  • src/main/runtime/agent-session-record-store.ts
  • src/main/runtime/agent-session-record-unsupported-schema.test.ts
  • src/main/runtime/agent-session-recovery-publish-fault.test.ts
  • src/main/runtime/agent-session-store-draft.ts
  • src/main/runtime/agent-session-store-row-rules.ts
  • src/main/runtime/agent-session-store-serialization.ts
  • src/main/runtime/agent-session-store-transaction-queue.ts
  • src/main/runtime/agent-session-store-transactions.test.ts
  • src/main/runtime/agent-session-store-transactions.ts
  • src/main/runtime/agent-session-tab-table.ts
  • src/main/runtime/agent-session-unreadable-record-salvage.test.ts
  • src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
  • src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts
  • src/main/runtime/rpc/methods/agent-launch-replay.test.ts
  • src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts
  • src/main/runtime/structured-agent-session-host-refusal.test.ts
  • src/main/runtime/structured-agent-session-install-failure.test.ts
  • src/main/runtime/structured-agent-session-integration.test.ts
  • src/main/runtime/structured-agent-session-journal-open.ts
  • src/main/runtime/structured-agent-session-record-import-install.test.ts
  • src/main/runtime/structured-agent-session-rollback-compatibility.test.ts
  • src/main/runtime/structured-agent-session-runtime.test.ts
  • src/main/runtime/structured-agent-session-runtime.ts
  • src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts
  • src/main/runtime/structured-agent-session-startup-reconcile.test.ts
  • src/main/runtime/structured-agent-session-startup-tab-restore.test.ts
  • src/main/runtime/structured-agent-session-tab-index-seed.ts
  • src/shared/agent-session-next-fence.ts
  • src/shared/agent-session-operation-ledger.ts
  • src/shared/agent-session-record.ts
  • tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts
  • tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts
💤 Files with no reviewable changes (14)
  • src/main/runtime/agent-session-recovery-publish-fault.test.ts
  • src/main/runtime/agent-session-record-store-security.test.ts
  • src/main/runtime/agent-session-record-store-security.ts
  • .github/workflows/pr.yml
  • src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts
  • tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts
  • src/main/runtime/agent-session-store-serialization.ts
  • src/main/runtime/agent-session-lease-transitions.ts
  • tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts
  • src/main/runtime/agent-session-backup-recovery.test.ts
  • src/main/runtime/agent-session-store-transaction-queue.ts
  • src/main/runtime/agent-session-record-unsupported-schema.test.ts
  • src/main/runtime/agent-session-unreadable-record-salvage.test.ts
  • src/main/runtime/agent-session-record-store-write.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.

Comment on lines +79 to +89
const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined
if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) {
const runtimeFence = safeFence(rawLease.runtimeFence)
const minimumNextFence =
'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined
? safeFence(rawLease.minimumNextFence)
: undefined
if (runtimeFence !== null && minimumNextFence !== null) {
floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence }))
}
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The set-aside copy's runtimeFence is ignored when its minimumNextFence is not a safe integer.

withFloorAboveSetAsideCopy raises the floor only when both runtimeFence and minimumNextFence pass safeFence. A set-aside copy is by definition a row this build cannot read. Its minimumNextFence can be null, negative, or a different type in a newer shape. In that case the floor ignores the set-aside runtimeFence, even when it is valid.

Example: the readable copy has fence 2 and the set-aside copy has runtimeFence: 7 with an unparseable minimumNextFence. The floor is 3. Later grants then climb through 3…7. A grant of 7 collides, under strict equality, with the lease the set-aside writer may still hold. This defeats the invariant that the comment on Line 72-76 describes ("The next grant must clear both").

Treat the two fields independently. Each valid value must raise the floor.

🛡️ Proposed fix
-  if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) {
-    const runtimeFence = safeFence(rawLease.runtimeFence)
-    const minimumNextFence =
-      'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined
-        ? safeFence(rawLease.minimumNextFence)
-        : undefined
-    if (runtimeFence !== null && minimumNextFence !== null) {
-      floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence }))
-    }
-  }
+  if (typeof rawLease === 'object' && rawLease !== null) {
+    const runtimeFence =
+      'runtimeFence' in rawLease ? safeFence(rawLease.runtimeFence) : null
+    const minimumNextFence =
+      'minimumNextFence' in rawLease ? safeFence(rawLease.minimumNextFence) : null
+    if (runtimeFence !== null) {
+      floor = Math.max(floor, runtimeFence + 1)
+    }
+    if (minimumNextFence !== null) {
+      floor = Math.max(floor, minimumNextFence)
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined
if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) {
const runtimeFence = safeFence(rawLease.runtimeFence)
const minimumNextFence =
'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined
? safeFence(rawLease.minimumNextFence)
: undefined
if (runtimeFence !== null && minimumNextFence !== null) {
floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence }))
}
}
const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined
if (typeof rawLease === 'object' && rawLease !== null) {
const runtimeFence =
'runtimeFence' in rawLease ? safeFence(rawLease.runtimeFence) : null
const minimumNextFence =
'minimumNextFence' in rawLease ? safeFence(rawLease.minimumNextFence) : null
if (runtimeFence !== null) {
floor = Math.max(floor, runtimeFence + 1)
}
if (minimumNextFence !== null) {
floor = Math.max(floor, minimumNextFence)
}
}

@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 critical issues — one behavior to confirm, plus a coverage note and two nits.

Reviewed changes — the full D-stage diff (68 files) moving native-chat agent-session records out of agent-sessions/agent-sessions.json and into the per-host chat journal database.

  • Schema v3 → v4 — adds agent_session_{records,operations,retired_claim_keys,tabs,store_meta}; the copy of agent-sessions.json runs inside the same transaction as the table creation and the user_version bump, and "copied" is exactly user_version >= 4. An owed copy makes the tables, keeps version 3, and retries next launch.
  • Row store — the whole store is read once into memory; each mutation drafts, applies, diffs by identity, validates changed rows with the load rules, writes only those rows in one journal transaction, then publishes. Unreadable rows are derived at load and never rewritten.
  • One-time import — reads the file with main's loader (.bak fallback, salvage, fence floors), distinguishes a transient read (unavailable → owed) from an unusable file (unusable → reported, never re-imported), and inserts rows with INSERT OR IGNORE.
  • Read-only / damage behavior — a newer database opens read-only and writes return the journalWrittenByNewerOrca refusal; a Stop runs from the committed ledger when its row cannot be written.
  • Runtime wiring — hasPersistedStructuredAgentSessionStore answers from database rows (or the legacy file while owed), and the host install reads the legacy file before opening.

I read the production paths and the new/changed tests, and ran three specialist passes (migration atomicity, presence-check reachability, row-diff persistence); none found a concrete bug. The intentional tradeoffs (one-time import, the downgrade window, shared SQLite damage) are documented and pinned by tests.

Technical details
# Verified as correct

- `migrateJournalSchema` cannot leave `user_version` disagreeing with the tables: table creation,
  the records write and the bump are one `runJournalTransaction`; a throw rolls back to 3/0.
- `insertAgentSessionStoreRowsIfAbsent` is all `INSERT OR IGNORE`, so an owed-window chat keeps its
  rows through the copy.
- `serializeChangedRows`'s `published.size + added !== next.size` shortcut is algebraically exact for
  object-valued maps, and the tab check uses content equality on the clone.
- The `unavailable` vs `unusable` split keys off the `cause.code` on the `agent_session_store_corrupt`
  error that `loadAgentSessionStore` throws for a non-ENOENT read failure.

ℹ️ Removed cross-version downgrade coverage

The two tests/e2e/cross-version-wire/agent-session-*-downgrade tests and the pr.yml line that ran one are deleted; the replacement is openAsVersion3Build inside journal-database-records-migration.test.ts, a hand-written simulation of the older build's open. That pins this build's view of a v3 build, but it is not the real released binary, so a divergence between the simulation and the actual v1.4.x open would no longer be caught. Reasonable given this build never writes the JSON file — worth a deliberate note rather than a change.

Technical details
# Affected sites
- `tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts` — deleted
- `tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts` — deleted
- `.github/workflows/pr.yml:865` — the runner line for the first is removed
- `src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts` — `openAsVersion3Build` simulates the v3 latch

## Open questions for the human
- Is the byte-identity assertion on the untouched `agent-sessions.json` (already covered in
  `agent-session-legacy-record-import.test.ts`) considered sufficient replacement for the deleted
  real-binary downgrade tests?

ℹ️ Nitpicks

  • src/main/runtime/agent-session-store-draft.ts:59 — prior === undefined && !published.has(key); the prior === undefined term is implied by !published.has(key).
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.ts:220 — this.deps.journalDatabase.legacyRecordImportOwed === true on a boolean field.

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

try {
admitted = await request.store.admitMutationOperation(operation)
} catch (error) {
if (plan.runsWithoutLedgerRow) {

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.

runsWithoutLedgerRow is caught for every thrown admission error, not only storage damage. On a database a newer Orca wrote, admitMutationOperation throws the read-only refusal — but cancelPlan also sets conversationWrite, which bypasses the lease check in admitAgentSessionMutation, so a Stop takes this branch, re-evaluates from memory, and runs plan.run instead of returning the journalWrittenByNewerOrca refusal. The PR description lists Stop among the actions that say "update Orca" on a newer database, and the structured-agent-session-queued-newer-orca.test.ts case covers send/Send-now/Delete/Resume but not Stop. Please confirm whether Stop should bypass a read-only store; if not, gate the fallback to damage (journalCorrupt / a full-disk error) and add a test.

Technical details
# Stop on a read-only store bypasses the write refusal

## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts:182` — `runsWithoutLedgerRow: true` is set for every cancel plan, and Stop additionally sets `conversationWrite` (:180)
- `src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts:129-131` — the flag is checked before the refusal branch, so the read-only error never reaches `journalOpenRefusal(error)`
- `src/main/runtime/agent-session-store-transactions.ts:108-110` — a read-only database throws `journal_read_only` from `commit` for any non-`inMemoryWhenReadOnly` transaction
- `src/shared/agent-session-mutation-envelope.ts:118-120` — a `conversationWrite` returns `admit` without consulting the lease

## Required outcome
- Either Stop intentionally reaches the agent on a read-only store (then correct the PR description and pin it with a test), or the fallback only applies when the storage is damaged/unavailable and a read-only store still answers `journalWrittenByNewerOrca` to Stop.

## Open questions for the human
- Is `runsWithoutLedgerRow` meant to be narrower than "any admission error"?

@brennanb2025
brennanb2025 merged commit 5cda0f4 into main Sep 30, 2026
108 of 112 checks passed
brennanb2025 added a commit that referenced this pull request Sep 30, 2026
#24006 moved the session records into the chat journal database and removed
the store's transaction queue; #23935's commitConversationClear now rides the
new store's transact, and the queue split this branch made for max-lines goes
with the queue.
brennanb2025 added a commit that referenced this pull request Sep 30, 2026
…, where main's tests still used the old calls

#24006 moved the store into the journal database and #23684 added a test on the
old open call; main's close now takes a cause. Four tests catch up.
brennanb2025 added a commit that referenced this pull request Sep 30, 2026
Main moved the agent-session record store into the chat journal database
(#24006): a transaction now hands each mutation a draft and commits only
that draft. The combined admit-and-claim still mutated the store's
committed state, so after the merge neither the admission nor the claim
was written and two concurrent launches could both run. It now applies
both steps to the draft, like every other store mutation.
brennanb2025 added a commit that referenced this pull request Sep 30, 2026
…ges crossed (#24231)

* test: two tests catch up with the journal-database store and the close cause

#24006 moved the session store into the chat journal database, and #23467 made close take a cause; #23684's and an older tab-table test still used the old calls, so main's typecheck fails.

* test: the startup-reconcile close test passes the close cause too
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