Skip to content

fix(scanner): never build a persistent scanner for a cold-start scan - #879

Open
RonenMars wants to merge 1 commit into
mainfrom
fix/cold-start-non-persistent-scan
Open

fix(scanner): never build a persistent scanner for a cold-start scan#879
RonenMars wants to merge 1 commit into
mainfrom
fix/cold-start-non-persistent-scan

Conversation

@RonenMars

Copy link
Copy Markdown
Owner

Closes #876.

What was wrong

Three sites construct a scanner as statCache ? { persistent: false } : undefined, and newScanner resolves undefined to the scanner's own default, which is persistent.
The undefined branch is taken exactly when buildStatCache finds no stat rows, and that is the state tb-streamer cache clear and the integrity monitor's reset-and-rescan both create.
So the one condition under which the streamer reads the scanner's persistent index is the condition the documented recovery step produces, and a cold start imports whatever that index still remembers instead of parsing the tree.

Measured on the Windows box after a cache clear plus a restart: 172 rows in conversation_meta against 51 transcripts on disk, 122 of them naming files verified absent with existsSync, and a high-severity integrity alert that forced an automatic cache backup.
The source index at ~/.config/threadbase-scanner/index.db held 643 conversations and 77 scanned directories, many deleted weeks earlier.
None of the 122 carried a tail, so they were metadata ghosts rather than recoverable history.

The change

All three sites now pass { persistent: false } unconditionally.
The streamer never serves from the scanner's persistent index in any other state, so giving it up costs nothing, and this covers the manual clear, the monitor's reset-and-rescan, and a fresh install alike.

I deliberately did not centralise the default inside newScanner.
That would be one line, but it removes the only read of persistenceDisabled and leaves that field and disablePersistence() dead, pulling the cache-open-failure path in server.ts into an otherwise three-line diff.

I also left the two no-argument newScanner() callers in conversations.handlers.ts alone, since those are single-file and cold-read paths rather than the cold-start scan this issue describes.
They are worth a separate look.

Verification

The new test fails without the fix and passes with it, which I checked by reverting the source and re-running rather than assuming.
Without the fix all three cases fail with expected undefined to deeply equal { persistent: false }.

Full suite on this branch: 284 files, 3062 passed, 0 failed.
Lint clean across 518 files and tsc -p tsconfig.build.json --noEmit clean.

The suite reports 30 skipped here against 25 in a built checkout.
That delta is environmental and accounted for exactly: three describe.skipIf(!existsSync(SCRIPT)) blocks gate on dist/*.cjs, which a fresh worktree has not built, and they contain 1 + 2 + 2 tests.

The three scan-construction sites passed `statCache ? { persistent: false } : undefined`, and the undefined branch falls through to the scanner's persistent default.
That branch is taken exactly when the cache holds no stat rows, which is the state `tb-streamer cache clear` and the integrity monitor's reset-and-rescan both create.
A cold start therefore read the scanner's own persistent index instead of parsing the tree, importing rows for files deleted long ago.
On one machine that turned an emptied cache into 172 rows against 51 transcripts on disk, 122 of them naming files that no longer exist, and raised a high-severity integrity alert.
The streamer never serves from the scanner's persistent index in any other state, so forcing non-persistent costs nothing.

Closes #876
@RonenMars
RonenMars force-pushed the fix/cold-start-non-persistent-scan branch from 0a5c1ac to 361ef2f Compare September 12, 2026 06:30
RonenMars added a commit that referenced this pull request Sep 12, 2026
`startServer` in `__tests__/codex-active-writer.test.ts` builds a
per-server `cacheDir` but lets `runtime.db` fall through to the shared
default.

The adopt cases register a managed session, and `runtime.db` is the
authoritative store that deliberately outlives `server.close()`.
So a later server's boot reconcile picks that row up through
`reconcilePreviousSessions` and probes the previous case's pid with
`isPidAlive`, which is `process.kill(pid, 0)`.
That sends no signal, but the two cases asserting "and kills nothing"
spy on `process.kill` broadly enough to record it, and they fail.

## Why this is worth fixing rather than tolerating

It passes on main today only because the reconcile is fire-and-forget
and usually lands outside the spy's window.
Running just the takeover case and one refusal together already fails on
main: `npx vitest run __tests__/codex-active-writer.test.ts -t "take"`.
Anything that shifts startup timing turns a latent ordering dependency
into a red suite, and the failure points at the scanner rather than at
test isolation, which costs the next reader real time.

## The change

Hoist the temp dir into a local and pass `runtimeDbPath: join(dir,
"runtime.db")` alongside the existing `cacheDir`, matching how
`access-probe.test.ts` and `auto-resume-on-boot.test.ts` already isolate
it.

## Verification

The file passes 12/12 on main with this fix.
It also passes 12/12 with the #876 scanner change applied on top, which
is the state that surfaced the problem.
Full suite on this branch: 283 files, 3062 passed, 0 failed, lint clean
across 517 files.

Unblocks #879.
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.

P2: clearing the cache refills it from the scanner's stale persistent index

1 participant