Skip to content

perf(native-chat): the agent-session store stops re-reading its own writes - #23115

Closed
brennanb2025 wants to merge 12 commits into
mainfrom
brennanb2025/native-chat-store-perf
Closed

brennanb2025 wants to merge 12 commits into
mainfrom
brennanb2025/native-chat-store-perf

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​686 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​686
Prod 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​514 $\color{#cf222e}{\Huge{\mathbf{−}}}$​218 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​296

ELI5

Orca keeps one file that records every native chat and who owns it. Before this change, every small edit to that file first re-read, re-checked and re-encoded the whole thing, just to see whether another Orca process had changed it. With about 1,300 chats that is slow, and startup does it once per restored chat. Now Orca remembers a fingerprint of the exact bytes it last read or wrote, and it skips the re-read when the bytes on disk still match.

What Changed

Before: every agent-session store transaction called refreshExternallyChangedState unconditionally. That meant a full read and JSON parse of agent-sessions.json, validating every record (including a provider-handle chain replay that grew with the square of the chain length), then stringifying and hashing the whole state. Each write stringified the whole store twice more. Restoring 247 chats from a 1,301-record store ran about 276 such transactions at startup, which measured about 3 s of main-process CPU on a 24-core Windows machine. Every attach runs about five, and so do reveals and mobile re-holds.

After:

  • Exact change detection by bytes. The store keeps the sha256 of the exact bytes it last loaded or wrote. A refresh reads the file and hashes it. If the hash matches, it skips parse, validation and revision. If it doesn't match, it takes the old full path unchanged, so writes by another Orca process, orcad or an older build are still adopted exactly as before. When a load also had to read the backup file (to recover a row the main file marks unreadable), the backup's hash is part of the fingerprint too, so a change to either file triggers the full path. If the backup could not be read at all, no fingerprint is kept.
  • Serialize once per write. The save returns the exact text it wrote. Both the store revision and the byte fingerprint come from that text.
  • Rows are validated when written, not when next read. The re-read used to be the only thing that caught an invalid row this process wrote. Now the rows that changed are checked at write time, against the same rules a load applies, all kept in one module. An invalid row rejects its own transaction. This also closes a latent bug: a tab id containing : used to be written, after which every later load fell back to the backup. It is now refused with agent_session_store_write_invalid.
  • Changes are staged, then published. Each transaction applies to a draft. The draft becomes the live state only after the durable rename succeeds. Rolling back just means discarding the draft, so the old snapshot-and-restore code is gone. Readers outside a transaction can no longer see a write that later rolls back.
  • Chain validation is linear. One non-copying transition check is shared by appending a handle link and validating a chain.

Cost after the change: a transaction that changes nothing is O(file size) for reading and hashing plus some map copies. It no longer parses, validates or stringifies. While the main file holds a row marked unreadable that it has no valid copy of, the backup file is read and hashed too, which main already did on every transaction. A writing transaction serializes once. It is still not constant-time, and the commit messages say so.

Why

The expensive check answered a real question: did another process change the file? Other writers exist: orcad, a second dev instance on the same profile, an older build during an upgrade. So the check can't just be dropped. Comparing the exact bytes answers the same question cheaply, and it stays content-exact across processes with no file-format change.

Alternatives considered:

  • Comparing file timestamps or size. Rejected. Coarse timestamps and inode reuse can hide a change, and this is the file that decides which process owns each chat.
  • Batching several queued transactions into one write. Rejected for now. Startup awaits each chat in turn, so there is nothing to batch there. It would also weaken the fence bound used after backup recovery. It can be revisited only if a measured burst that this change doesn't fix turns up.
  • Moving the store to per-record files or SQLite. Rejected. Older builds read this JSON file, so the format has to stay.

Linked Issue

No ticket. This was found by profiling native-chat startup with about 250 restored chats. It is one of two costs that grow with the number of chats; the other, re-encoding the status snapshot on every update, was already fixed on main by #22536.

Visual Proof

There is no UI change; the CPU numbers are under Testing. These hidden-window captures come from a restart check on a Mac with 247 restored native chats. They show nothing changes for the user.

Restored chat tabs after a restart (every launch showed 247 of 247, with history):

Restored chat tabs after restart

Close and reopen across a restart: 7 tabs, then 3 closed, then 1 reopened from Session History, then the result after a restart. The two closed chats stay closed, and the reopened chat keeps its history.

Before After closing 3 After reopening 1 After restart
before closed reopened restarted

Testing

  • New tests, each paired with a deletion ablation that turns it red:

    • the round-trip property revision(load(serialize(s))) === revision(s);
    • an invalid write (a live lease whose head link doesn't match its proven link) is refused and leaves memory and disk unchanged;
    • the no-op path does zero parses, validations and serializations, and counts the bytes hashed; a write does exactly one serialize;
    • an external change is adopted, and a pretty-printed but equal file is handled as before;
    • after a row is recovered from the backup, a change to either file is still adopted, including a peer that copied the main file over the backup and died before publishing, until our own write stores the recovered row;
    • a backup that failed to read is read again on the next transaction, and a backup that gains the row while the main file stays byte-identical is still recovered;
    • our own write, while a row stays unrecovered, records the backup it just replaced, so a backup restored afterwards is noticed;
    • a record filed under another session id is still set aside on its own, as on main, and the rest of the file loads;
    • a failed save after a partial write keeps the old fingerprint and the old state;
    • no phantom reads during a pending transaction;
    • a 256-link chain validates in linear checks.
  • Unchanged and green: the rollback-compatibility, backup-recovery-fence and lease-renewer tests, plus the cross-version wire tests.

  • Measurement: same Windows machine (24 logical CPUs) and seed profile as the startup measurement: 247 restored chats, 1,301 records. Two runs per build, each confirmed to restore all 247 chats. Main is pinned at this PR's merge base 80ff6c4e3e.

    Per startup main this PR
    Record-store CPU while the chat tab list loads ~2.8 s ~0.19 s
    Chat tab list, median 10.45 s 8.25 s (−2.2 s)
    Full store parses 262 1 (the open)
    Full-state serializations 288 14 (one per real write)
    • Both builds run the same 261 transactions and the same number of saves.
    • The file is still read and hashed on every transaction, about 0.12 s in total, but it is no longer parsed or re-serialized.
    • Journal, pragma and status costs are unchanged.
  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Review

Agent skill upstream boundary

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

Notes

  • Cross-platform: nothing platform-specific. The rename and fsync helpers are unchanged and already handle Windows.
  • SSH and remote: the store lives on the host that runs the agents, and each host has its own copy.
  • Mixed versions: the file format is unchanged, so older builds and orcad read and write it as before, and their writes are detected by bytes.
  • Mobile: no wire change.

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)

Appending a link and validating a persisted chain now share one transition
check on (head, link, seen link ids). Validation no longer copies the chain
per link or scans it for duplicate ids, and checks each link's shape once.
…ransaction

Every transaction re-read, re-parsed and re-validated the whole
agent-sessions.json to learn whether another writer changed it, then
serialized it twice more on a write.

- Keep the sha256 of the exact primary bytes last loaded or written. A
  refresh reads the file as a buffer and hashes it; on a match it skips
  parse, validation and revision. On a mismatch it decodes that same buffer
  and runs the full load as before. No hash is kept after a load that
  salvaged rows from the backup, after a backup recovery, or when the
  primary is absent.
- The save returns the text it wrote; the revision and the byte hash both
  come from it, so a write serializes once.
- Rows a transaction changed are checked against the same rules a load
  applies before they are saved. A load no longer re-checks our own bytes,
  so an invalid row is refused at write time instead of living in memory
  while every other reader quarantines it.
- A transaction applies to a draft that is published only after the durable
  rename. Rollback is discarding the draft, and a reader can no longer see a
  change whose save later fails.

A transaction is still O(file bytes): it reads and hashes the whole file.
@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 4ac88df1-75fe-41e5-8f4b-cf3b7b28eabf

📥 Commits

Reviewing files that changed from the base of the PR and between b5e51ae and c7b027f.

📒 Files selected for processing (5)
  • src/main/runtime/agent-session-record-store-file.ts
  • src/main/runtime/agent-session-record-store-write.ts
  • src/main/runtime/agent-session-record-store.ts
  • src/main/runtime/agent-session-store-exact-change.test.ts
  • src/main/runtime/agent-session-store-transaction-queue.ts

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


📝 Walkthrough

Walkthrough

The agent session store now validates persisted rows through shared rules, tracks primary and backup file inputs, and reports backup salvage. Transactions operate on drafts and publish changes only after saving. Provider handle chain validation and appending now share a transition function. Tests cover store recovery, transaction publication, transaction costs, and provider handle validation work.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to c7b02

If the primary has an unreadable record and the backup cannot be read temporarily, a later store write can erase the backup copy needed to recover that record. Resolve or explicitly accept this data-loss risk before merging.

Security Architecture Review

Security architecture risk: 🔵 Low · up to c7b02

The examined paths preserve the existing session-validation and ownership checks, and publish normal transactions only after a durable write. No security regression was established. Risk is not minimal because the new draft mechanism relies on callers replacing nested values rather than changing them in place, and coverage of all possible callers is incomplete.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The directly affected authority is the locally persisted collection of agent sessions, operation rows, and leases; the examined changes do not establish a new remote or cross-service entrypoint.

Trust Boundaries and Controls

  • observed — Primary-file rows are parsed and checked before entering live state. Backup salvage copies only a matching missing session from a parsed backup; an unreadable backup does not establish a reusable input fingerprint.

Resilience and Maintainability Implications

  • inferred — An in-place edit to a shared nested draft value could change published memory without a durable commit or be missed as a no-op. The examined record mutation path instead sets a replacement value; reachability of the unsafe pattern through other callers remains unverified.

Hardening Proposals

  • proposed — Enforce immutable replacement of draft map values, or give drafts ownership of nested values, so a future caller cannot bypass change detection and atomic publication with an in-place edit.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 13 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description is detailed and covers the required change, rationale, testing, compatibility, and checklist items. However, it states that writes are refused when the backup cannot be read, while the… Update the description to match the final backup-read behavior and explain the resulting write and recovery semantics. Add the required linked issue, or document the applicable maintainer exception.
✅ Passed checks (3 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the primary change: avoiding repeated reads of the agent-session store's own writes to improve native-chat performance.
Full details: Description check

Explanation

The description is detailed and covers the required change, rationale, testing, compatibility, and checklist items. However, it states that writes are refused when the backup cannot be read, while the latest commit reverts that behavior. It also provides no linked issue despite the template requiring one unless the author is a maintainer.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

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.

The write-time check validated the in-memory row, which can hold members
JSON drops (an enumerable `undefined` option, an own `launchEnv: undefined`).
Main wrote and reloaded such rows fine; this branch refused the write. Each
changed row is now checked in its written JSON form, so the check refuses
exactly what a load would. The salvage test also now asserts the refresh
stops re-reading once the salvaged row is written.
A transient read failure on the backup while salvaging a quarantined row
left the load marked exact, so later transactions skipped the reload that
would retry the salvage, and the next write copied the primary over the
only valid copy of that chat. The load now stays non-exact until the
backup is read or known absent.

@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 inline consistency note and one coverage gap worth a look.

Reviewed changes

Reviewed the full 12-file diff at 1c09e60 (base main @ 4cafa50). This replaces the store's unconditional read-parse-validate-serialize on every transaction with a byte-fingerprint fast path, and moves row validity checks to write time.

  • Byte-fingerprint fast path — refreshExternallyChangedState reads the primary once and compares its sha256 to the hash of the bytes last loaded or written; a match skips the full load. A null fingerprint (no primary, or a load that salvaged rows from the backup) forces the slow path.
  • Serialize once per write — saveAgentSessionStore returns the exact text it published, and the queue derives both the revision and the byte fingerprint from it.
  • Write-time row validation — changed rows are checked before save against the same predicates a load applies, refusing agent_session_store_write_invalid (closes the latent invalid-tab-id bug that used to wedge the next load onto the backup).
  • Draft-and-publish — a transaction applies to a shallow draft that is published only after the durable rename; the old snapshot/restore rollback is gone and the queue's state is now a getter over the published state.
  • Linear handle-chain validation — one agentSessionProviderHandleTransition is shared by append and persisted-chain validation in place of the O(n²) repeated append. I traced it as behaviorally equivalent to the old append/validate pair.
  • Tests — exact-change (9), transaction-cost (4), provider-handle (28), plus the surrounding backup-recovery, rollback-compatibility, tab-table, legacy-handoff and record-store suites all pass locally.

ℹ️ The fast path rests on a replace-never-mutate invariant nothing states or tests

agentSessionStoreDraftChanges detects a changed row only by object identity (published.get(key) !== value), and draftAgentSessionStoreState shallow-shares the record and operation objects between the draft and the published state. Every transaction-reachable transition I traced currently returns a fresh record, so this is sound today — but the invariant matters more now than it used to: an in-place mutation would change what readers see while changed stays false, so nothing would be written, assertAgentSessionStoreDraftReadable would never inspect the row, and the fingerprint fast path would never reload. That is a silent memory/disk divergence, and the new property test reads the file back from disk so it could not catch it. A one-line comment on draftAgentSessionStoreState/changedKeys asserting "transitions replace rows, never mutate them in place", or a focused test, would keep the guarantee from regressing.

Technical details
# Change detection relies on row replacement

## Affected sites
- `src/main/runtime/agent-session-store-draft.ts:15-24` — the draft shares `records`/`operations` values by reference with `published`.
- `src/main/runtime/agent-session-store-draft.ts:44` — change is detected by `published.get(key) !== value`.
- `src/main/runtime/agent-session-store-transaction-queue.ts:110-114` — a false `changed` skips both the save and `assertAgentSessionStoreDraftReadable`.
- `src/main/runtime/agent-session-store-transaction-queue.ts:137-142` — the fingerprint fast path then skips the reload.

## Required outcome
The "each transaction-reachable transition returns a new row object" invariant is documented where change detection lives, and ideally covered, so a future in-place mutation is caught rather than silently dropped.

## Suggested approach (optional)
Comment the invariant on `draftAgentSessionStoreState` and `changedKeys`; optionally add a test that exercises a representative set of transitions and asserts each replaced its row rather than mutating it.

## Open questions for the human (optional)
Is the round-trip property test intended to cover this, or would a dedicated test be preferred?

ℹ️ Nitpicks

  • isReadableAgentSessionStoreTab's docstring says "a table is readable when every entry is and no id repeats", but the helper only ever sees one entry — uniqueness is enforced in parsePersistedTabs. Consider moving that clause to the caller so the comment matches what the function checks.

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

Comment thread src/main/runtime/agent-session-record-store-file.ts

@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: e8912db6-348f-436c-8c5f-de0a55861468

📥 Commits

Reviewing files that changed from the base of the PR and between 1c09e60 and 0a494e6.

📒 Files selected for processing (2)
  • src/main/runtime/agent-session-record-store-file.ts
  • src/main/runtime/agent-session-store-exact-change.test.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 thread src/main/runtime/agent-session-record-store-file.ts Outdated

@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. The prior review's one minor, non-blocking note (record rows are validated through duplicated logic in parseState rather than the shared isReadableAgentSessionStoreRecord) remains open; nothing here changes that.

Reviewed changes

Reviewed the delta from 1c09e60 to 0a494e6 — the one commit pushed during the prior review.

  • salvagedFromBackup → dependsOnBackup — the load result now also reports backup dependence when the backup read throws a non-ENOENT error, so agentSessionStoreExactPrimarySha256 returns null and the byte-fingerprint fast path stays off.
  • salvageUnreadableRecordsFromBackup returns dependence, not only salvage — a transient backup read failure is treated as "the backup may still hold the row", so the next transaction re-reads it instead of trusting the primary's hash.
  • New regression test — seeds an unreadable primary row whose only valid copy lives in the backup, makes the .bak read fail once with EIO, asserts the row is absent after open, then that a following write re-reads the backup, salvages the row and persists it.

This closes a real edge in the previous revision: a single transient backup read failure latched the primary's hash, and the next save's copy step (copyFileDurable(primary, backup)) then overwrote the only valid copy of the quarantined row. The new test asserts exactly that survival, so it fails against the prior revision rather than passing vacuously. I re-ran the exact-change, transaction-cost and backup-recovery suites and they are green.

One observation, not a request: a backup that reads successfully but fails parseState (corrupt or older-schema) still reports dependsOnBackup: false. The new comment "or it could not be read" reads as if it covers that case too. It is defensible — such a backup is proven unusable, and overwriting it loses nothing recoverable — but if the intent is that any backup the load could not use keeps the slow path on, that branch would return true.

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

A load whose salvage reads the backup now records the backup's hash beside the primary's, so the skip holds only while both files still hold those bytes. A backup that gains a valid copy of a quarantined row is salvaged on the next transaction, as a full re-read would, instead of being overwritten by the next rotation. The load and the write check share one record rule.
… a quarantined row

A write used to copy the primary over the backup even when the backup held the only readable copy of a row the primary quarantines. Two cases lost that row: the backup could not be read, so its copy was never salvaged, or the write that would persist a salvaged row died after the rotation and before the primary landed.

Now a write that would rotate an unreadable backup is refused and retried on the next transaction, which reads the backup again, and a store holding salvaged rows rotates in the state that holds them instead of the primary's bytes. A deferred rewrite at open tolerates the refusal.
…ead, as before

This reverts commits b5e51ae and 68c354e.

Refusing the write whenever the backup could not be read turned a backup read error into a failed send, reservation, rename or lease renewal, for as long as the error lasted, on any profile holding a quarantined row. Keeping the backup instead would let the primary move more than one generation past it, which the backup-recovery fence cannot cover. Both fixes address a gap that exists on main and belong in their own change.

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

Reviewed changes

Reviewed the delta from 0a494e6 to b5e51ae (4 commits, 8 files) on top of the full PR. The prior pullfrog inline thread is addressed and resolved.

  • The parse skip is keyed on every file the load read — LoadedAgentSessionStore now reports an inputKey of the primary plus the backup ('unread' / 'absent' / sha256), and refreshExternallyChangedState re-reads both before skipping, so a backup that gains a quarantined row can no longer hide behind an unchanged primary.
  • A write refuses to rotate an unreadable backup — with a quarantined row outstanding and the backup unreadable, a writing transaction aborts with agent_session_store_backup_unreadable instead of copying the primary over the only readable copy; the open tolerates the refusal and defers the rewrite.
  • A salvaged write rotates the repaired state into the backup — saveAgentSessionStore takes backupText, so a write holding salvaged rows backs up serialize(published) rather than the primary bytes that lack them; the post-write key matches whichever bytes were actually rotated in.
  • The record rule is shared — parseState accepts record rows through isReadableAgentSessionStoreRecord, keeping isPersistedAgentSessionRecord only to choose the quarantine reason.
  • Tests — new cases cover a backup that gains the row while the primary is byte-identical, a write keyed on the backup it rotated, an unreadable backup holding the only copy, a refused open rewrite, and a crash after backup rotation; all pass.

Each new test fails against the pre-fix revision, and a data-loss-focused audit of the rotation/fingerprint path found no reachable sequence that destroys the only readable copy of a quarantined row.

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

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

Reviewed changes

Reviewed the delta from b5e51aeff to e595c04045 (2 commits, 8 files).

  • Reverted the backup-unreadable write refusal — c7b027fb0d drops saveAgentSessionStore's backupText option, the salvage latch, the agent_session_store_backup_unreadable error and the open-time tolerance, so a validated write again rotates the backup unconditionally, as on main; the three tests covering those paths are removed with it.
  • Kept the input key keyed on the rotated backup — a write still records readPrimarySha256 (the primary bytes copyFileDurable rotates in) while a load would read the backup, and 'unread' otherwise, so a backup restored afterwards is still detected. That path is unchanged and stays exact.
  • Lint — e595c04045 marks the two test Proxy Reflect.get forwards with anti-slop/no-reflect-get disables matching the repo convention.

The revert widens main's existing crash window (a write can copy the primary over a backup holding the only readable copy of a quarantined row), but that matches pre-PR behavior and the commit subject states it plainly, so it reads as a deliberate scope decision rather than a regression this PR introduces.

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

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for review at e595c04

Result: all CI checks pass at e595c04. Five review rounds and an architecture review are done. The last round found nothing to fix, and QA on a real restart passed. The fix stays as designed: skip re-reading the store file when its bytes are unchanged, check each changed row when it is written, and show a change to readers only after it is saved. There is no file-format change and no UI change.

Fixed during review:

  • Round 1 (1c09e60): the write-time check now tests each changed row as a later load will read it back from disk, not the in-memory object. JSON drops fields that are undefined, so a row that main would write and load fine could otherwise be refused. The salvage test also now fails when the code it guards is removed.
  • Round 2 (0a494e6): a temporary error reading agent-sessions.json.bak during salvage was treated as "nothing to recover". The store could then skip re-reading, and its next write could overwrite the only good copy of a recoverable chat. That load no longer lets later transactions skip the re-read.
  • Round 3 (627eb55, 257bdf0): the "unchanged, skip it" check is now keyed on every byte the load read. That is the primary file, plus the backup whenever the load read it. The load and the write-time check now share one record rule. New tests cover the fingerprint after a write and the record-key rule.
  • Round 3 also tried a fix for how the backup file is rotated (68c354e). Round 4 reverted it (c7b027f) because it could refuse every chat action for as long as .bak stayed unreadable. Main does not do that, and keeping the old backup instead would weaken the fence recovery bound unless the file format changed.
  • e595c04 marks the tests' two counting-proxy Reflect.get forwards for the static-analysis lint. It changes tests only.

Checked on every round:

  • A load is never stricter than main. Each load rule was compared with main and has the same outcome, so a file main writes still loads the same way.
  • Serialization is byte-identical to main's.
  • Writes from orcad, a second instance or an older build are still detected and adopted exactly as before.
  • Each new test was run with the code it guards removed and failed each time.
  • pnpm tc passes, the store tests pass, and full-file oxlint and the changed-lines quality gate are clean.

Live QA: on a Mac, restarting with 247 restored native chats (1,301 records) restored all 247 tabs on every launch, each with its history. Closing 3 chats and reopening 1 from Session History survived a restart exactly as expected. The saved file matched main's, and no store errors were logged. Screenshots are in the PR body under Visual Proof.

Follow-ups, not in this PR:

  • Backup durability is getting its own PR off main. This is an existing problem on main that this PR does not make worse. When the backup cannot be read, or holds the only good copy of a chat the primary could not read, the next write still overwrites it with a copy of the primary.
  • An open question that has not been checked: when salvage restores an older lease from the backup, it does not raise that lease's fence floor. It is folded into the backup-durability follow-up.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Ready to merge

Checked at head e595c04045: mergeable, with 30 checks passing and none failing.

  • Scope: matches the plan, a pure performance change. The store skips re-reading and re-validating the file when the bytes it last validated or wrote are unchanged. Each change is validated at write time instead, it serializes once per write, and each transaction works on a draft that becomes live only once saved.
  • Measured, Windows high spec, 247 chats, 2 runs per arm: record-store CPU while chats are listed drops from about 2.8 s to about 0.19 s, and listing chats is 2.2 s faster. Full re-reads of the file go from 262 to 1. The number of saves is unchanged.
  • Upgrade safety: loading accepts everything main accepts. An upgrade test checks that a store main wrote still loads the same way.
  • Review: five loops, the last one clean. An architecture challenge kept the design. The one lint exception is a Proxy get trap in a test, where forwarding through Reflect.get is the intended use.
  • Separate follow-up: a data-loss case that already exists on main, where a row only the backup can recover is lost if the backup can't be read or a save dies mid-write, is fixed in the planned "backup durability" PR. That PR builds on this one.

@nwparker

Copy link
Copy Markdown
Contributor

Closing as superseded: #24006/#26038 moved agent-session records into the journal DB, and that store already writes only the changed rows.

@nwparker nwparker closed this Oct 14, 2026
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.

2 participants