diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts index 71556b6c..459d471e 100644 --- a/app/electron/cli.test.ts +++ b/app/electron/cli.test.ts @@ -473,25 +473,31 @@ describe('spawnCli', () => { }) describe('no-output watchdog (timeoutMs bounds SILENCE, not total runtime)', () => { - /** Emits one progress byte every `everyMs` for `ticks` ticks, then the payload. */ - function chattyBin(everyMs: number, ticks: number): void { + /** First stderr write is immediate after Node boot: that only removes the extra + * setInterval delay. The spawn-time silence timer still includes Node boot; + * the larger smoke window absorbs startup. Further ticks keep the process + * alive past `timeoutMs`. Not a production timeout change. */ + function chattyBin(everyMs: number, extraTicks: number): void { fakeBin( 'chatty.js', - `let n = 0; + `let n = 1; + process.stderr.write('CODEBURN_PROGRESS {"kind":"tick","provider":"claude","done":0,"total":${extraTicks + 1}}\\n'); const t = setInterval(() => { - process.stderr.write('CODEBURN_PROGRESS {"kind":"tick","provider":"claude","done":' + n + ',"total":${ticks}}\\n'); - if (++n >= ${ticks}) { clearInterval(t); process.stdout.write(JSON.stringify({ ok: 1, ticks: n })); } + process.stderr.write('CODEBURN_PROGRESS {"kind":"tick","provider":"claude","done":' + n + ',"total":${extraTicks + 1}}\\n'); + if (++n > ${extraTicks}) { clearInterval(t); process.stdout.write(JSON.stringify({ ok: 1, ticks: n })); } }, ${everyMs});`, ) } - // The 0.9.20 failure class: a warm `optimize` measured 52.5s against a fixed - // 45s cap and was SIGKILLed even though the parse was making steady progress. - // Scaled down here — total runtime is 5x the window, so the OLD fixed-timeout - // code fails this test and only a resetting watchdog passes. + // Production already restarts the idle window on every stdout/stderr byte; + // this is not a product fix. The fixture used to wait one setInterval before + // the first byte, so a 600ms smoke window raced Node boot (independent 614ms + // fail). Immediate first stderr write removes only that delay — boot still + // counts. Extra ticks run ~4s under a 2s silence window, so a fixed + // total-runtime cap still fails this test. Not a production timeout change. it('never kills a child that keeps producing output past the window', async () => { - chattyBin(100, 15) // ~1.5s of work under a 600ms window - await expect(spawnCli(['optimize'], { timeoutMs: 600 })).resolves.toEqual({ ok: 1, ticks: 15 }) + chattyBin(400, 10) + await expect(spawnCli(['optimize'], { timeoutMs: 2_000 })).resolves.toEqual({ ok: 1, ticks: 11 }) }) it('kills a child that goes silent, measured from its LAST byte', async () => { diff --git a/tests/cache-refresh-lock-status-snapshot.test.ts b/tests/cache-refresh-lock-status-snapshot.test.ts index d36eef79..fdc434f6 100644 --- a/tests/cache-refresh-lock-status-snapshot.test.ts +++ b/tests/cache-refresh-lock-status-snapshot.test.ts @@ -1,14 +1,24 @@ import { afterEach, describe, expect, it } from 'vitest' -import { spawn, type ChildProcess } from 'child_process' +import { spawn, spawnSync, type ChildProcess } from 'child_process' import { createHash } from 'crypto' import { existsSync } from 'fs' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' -import { join } from 'path' +import { delimiter, join } from 'path' +import { pathToFileURL } from 'url' +import { acquireCacheRefreshLock } from '../src/cache-refresh-lock.js' import { saveStatusSnapshot } from '../src/session-cache.js' +const LOCK_WAIT_PROBE = pathToFileURL(join(process.cwd(), 'tests/fixtures/cache-refresh-lock-wait-probe.mjs')).href + const roots: string[] = [] +const asyncCli: { child: ChildProcess, promise: Promise }[] = [] + +function forgetAsyncCli(child: ChildProcess): void { + const i = asyncCli.findIndex(entry => entry.child === child) + if (i >= 0) asyncCli.splice(i, 1) +} async function waitFor(path: string, timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs @@ -49,7 +59,15 @@ function recordPath(cacheDir: string, queryKey: string): string { afterEach(async () => { delete process.env['CODEBURN_CACHE_DIR'] - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + const owned = asyncCli.splice(0) + try { + await Promise.all(owned.map(async ({ child, promise }) => { + try { await stopCliChild(child) } catch { /* still remove roots */ } + try { await promise } catch { /* spawn error or already rejected */ } + })) + } finally { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + } }) describe('status snapshot child-process write lock', () => { @@ -102,3 +120,276 @@ describe('status snapshot child-process write lock', () => { expect((await readdir(cacheDir)).filter(name => name.endsWith('.tmp') || name.endsWith('.lock'))).toEqual([]) }) }) + +// Post-merge review of PR #999: a `status --format menubar-json --no-optimize` +// poll that runs while ANOTHER process holds `session-refresh.lock` parses +// read-only and serves a degraded corpus (payload.stale === true), and a later +// in-process parse — the payload builder's own history re-parse, running after +// the holder releases — flips the module-level hydration global back to +// complete before the save point. A save gate consulting only that global +// persists the under-reported payload under the CURRENT corpus fingerprint, +// poisoning every future poll. This case spawns the real CLI against a live +// cross-process refresh lock, so it lives in the serial `test:locks` suite +// rather than the full parallel `npm test` pool. +const SNAPSHOT_FILE_RE = /^status-snapshot\.[0-9a-f]+\.json$/ +async function snapshotFileNames(cacheDir: string): Promise { + if (!existsSync(cacheDir)) return [] + return (await readdir(cacheDir)).filter(f => SNAPSHOT_FILE_RE.test(f)) +} + +function cliEnv(home: string, extraEnv: Record = {}): NodeJS.ProcessEnv { + return { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + HOME: home, USERPROFILE: home, + TZ: 'UTC', + ...extraEnv, + } +} + +type CliResult = { status: number | null, stdout: string, stderr: string, signal: NodeJS.Signals | null } + +// Same wall bound as runCli's spawnSync timeout — not a longer wait. SIGTERM +// grace is only the hang-escalation path, not the happy path. +const CLI_CHILD_MS = 60_000 +const TERM_GRACE_MS = 1_000 + +function stillRunning(child: ChildProcess): boolean { + return child.exitCode === null && child.signalCode === null +} + +function runCli(args: string[], home: string, extraEnv: Record = {}): CliResult { + const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: cliEnv(home, extraEnv), + encoding: 'utf-8', + timeout: CLI_CHILD_MS, + }) + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '', signal: result.signal } +} + +async function stopCliChild(child: ChildProcess): Promise { + if (!stillRunning(child)) return + await new Promise(resolve => { + let settled = false + const done = (): void => { + if (settled) return + settled = true + clearTimeout(grace) + resolve() + } + const grace = setTimeout(() => { + if (stillRunning(child)) child.kill('SIGKILL') + }, TERM_GRACE_MS) + child.once('exit', done) + child.kill('SIGTERM') + if (!stillRunning(child)) done() + }) +} + +function runCliAsync( + args: string[], + home: string, + extraEnv: Record = {}, + opts: { lockWaitReady?: string } = {}, +): { child: ChildProcess, promise: Promise } { + const nodeArgs = ['--import', 'tsx'] + if (opts.lockWaitReady) nodeArgs.push('--import', LOCK_WAIT_PROBE) + const child = spawn(process.execPath, [...nodeArgs, 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: cliEnv(home, opts.lockWaitReady + ? { ...extraEnv, CODEBURN_LOCK_WAIT_READY: opts.lockWaitReady } + : extraEnv), + }) + const promise = new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let settled = false + let termTimer: ReturnType | undefined + let killTimer: ReturnType | undefined + const clearTimers = (): void => { + if (termTimer !== undefined) clearTimeout(termTimer) + if (killTimer !== undefined) clearTimeout(killTimer) + termTimer = undefined + killTimer = undefined + } + const settle = (fn: () => void): void => { + if (settled) return + settled = true + clearTimers() + forgetAsyncCli(child) + fn() + } + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8') }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf-8') }) + child.once('error', err => { settle(() => reject(err)) }) + child.once('close', (status, signal) => { settle(() => resolve({ status, stdout, stderr, signal })) }) + termTimer = setTimeout(() => { + if (!stillRunning(child)) return + child.kill('SIGTERM') + killTimer = setTimeout(() => { + if (stillRunning(child)) child.kill('SIGKILL') + }, TERM_GRACE_MS) + }, CLI_CHILD_MS) + }) + asyncCli.push({ child, promise }) + return { child, promise } +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + message: { role: 'user', content: 'do the thing' }, + }) +} + +function assistantLine(sessionId: string, timestamp: string, messageId: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 500, output_tokens: 50 }, + }, + }) +} + +describe('degraded read-only parse is never persisted as a status snapshot', () => { + it('writes no snapshot for a lock-degraded poll, then resumes persisting on the clean pass', { timeout: 120_000 }, async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-snapshot-degraded-')) + roots.push(home) + const cacheDir = join(home, '.cache', 'codeburn') + // Two Claude config roots: the query below is scoped to one of them via + // --claude-config-source. The scoped path is what makes the PR #999 + // sequence reachable in a one-shot process: the payload builder captures + // the hydration verdict right after the (degraded) primary parse, then + // runs its OWN history re-parse over a wider range — a second, real + // parse that re-acquires the lock once the holder releases and flips the + // module-level hydration global back to complete before the save point. + const work = join(home, 'claude-work') + const personal = join(home, 'claude-personal') + await mkdir(join(work, 'projects', 'app'), { recursive: true }) + await mkdir(join(personal, 'projects', 'app'), { recursive: true }) + + // Two hours back, clamped inside the current UTC day (cliEnv pins + // TZ=UTC), so every session falls inside the 'today' query. + const now = new Date() + const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) + const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(work, 'projects', 'app', 'w1.jsonl'), + [userLine('w1', ts(0)), assistantLine('w1', ts(60_000), 'msg-w1')].join('\n') + '\n', + ) + await writeFile( + join(personal, 'projects', 'app', 'p1.jsonl'), + [userLine('p1', ts(30_000)), assistantLine('p1', ts(90_000), 'msg-p1')].join('\n') + '\n', + ) + const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(delimiter) } + + // Warm the session cache through the DEFAULT optimize path: it parses + // and persists the corpus but never reads or writes the status snapshot + // (main.ts: useSnapshot = !optimize). Also discovers the config-source + // id the scoped queries below select. + const warm = runCli(['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all'], home, env) + expect(warm.status, `stderr: ${warm.stderr}`).toBe(0) + const warmPayload = JSON.parse(warm.stdout) as { + current: { calls: number } + claudeConfigs: { options: Array<{ id: string, label: string }> } + } + expect(warmPayload.current.calls).toBe(2) + const workSourceId = warmPayload.claudeConfigs.options.find(o => o.label === 'claude-work')?.id + expect(workSourceId).toBeTruthy() + expect(await snapshotFileNames(cacheDir)).toEqual([]) + + // New activity in the SELECTED root that the warm cache has never seen. + // A read-only parse has no cache entry for it and must skip it, + // under-reporting the totals. + await writeFile( + join(work, 'projects', 'app', 'w2.jsonl'), + [userLine('w2', ts(120_000)), assistantLine('w2', ts(180_000), 'msg-w2')].join('\n') + '\n', + ) + + const args = [ + 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', + '--claude-config-source', workSourceId!, + '--no-optimize', + ] + + // A live, heartbeating owner holding the refresh lock, exactly as in + // cache-refresh-lock.test.ts: the pid answers signal 0 and the mtime + // stays fresh, so the child's primary parse can neither acquire nor + // take over — it parks in the wait loop. + const held = await acquireCacheRefreshLock({ cacheDir }) + expect(held.outcome).toBe('acquired') + if (held.outcome !== 'acquired') return + + let degraded + const readyPath = join(home, 'lock-wait.ready') + const running = runCliAsync(args, home, env, { lockWaitReady: readyPath }) + try { + // Release only once the child has failed its first exclusive create on + // session-refresh.lock. The wait-probe writes readyPath on that EEXIST, + // which is the observable that the first acquire did not succeed. + // Releasing earlier would let that FIRST acquire succeed and turn this + // into a clean run; releasing is what reports 'completed-by-other' and + // sends the primary parse down the read-only path while leaving the + // lock free for the payload builder's later history re-parse — the + // exact PR #999 sequence. + const parked = waitFor(readyPath, 60_000).then(() => 'parked' as const) + const first = await Promise.race([parked, running.promise.then(result => ({ result }))]) + if (first !== 'parked') { + throw new Error( + `CLI exited before lock wait: status=${first.result.status} stderr=${first.result.stderr}`, + ) + } + await held.handle.release() + degraded = await running.promise + } finally { + await stopCliChild(running.child) + await running.promise.catch(() => undefined) + await held.handle.release() + } + + if (degraded.status !== 0) { + throw new Error( + `CLI stuck after lock-wait ready: status=${degraded.status} signal=${degraded.signal} stderr=${degraded.stderr}`, + ) + } + expect(degraded.status, `stderr: ${degraded.stderr}`).toBe(0) + const degradedPayload = JSON.parse(degraded.stdout) as { stale?: boolean, current: { calls: number } } + // The primary parse went read-only behind the held lock and served the + // warm cache: w2 is missing from the totals and the payload says so. + expect(degradedPayload.stale).toBe(true) + expect(degradedPayload.current.calls).toBe(1) + // The gate: no snapshot may be persisted from this degraded payload, + // even though the history re-parse after the release flipped the + // hydration global back to complete before the save point. + expect(await snapshotFileNames(cacheDir)).toEqual([]) + + // The gate reopens: the identical query on a clean pass recomputes and + // persists a complete snapshot. + const clean = runCli(args, home, env) + expect(clean.status, `stderr: ${clean.stderr}`).toBe(0) + const cleanPayload = JSON.parse(clean.stdout) as { stale?: boolean, current: { calls: number } } + expect(cleanPayload.stale).toBeUndefined() + expect(cleanPayload.current.calls).toBe(2) + + const snapshots = await snapshotFileNames(cacheDir) + expect(snapshots).toHaveLength(1) + const record = JSON.parse(await readFile(join(cacheDir, snapshots[0]!), 'utf-8')) as { + payload: { stale?: boolean, current: { calls: number } } + } + expect(record.payload.stale).toBeUndefined() + expect(record.payload.current.calls).toBe(2) + }) +}) diff --git a/tests/cli-models-unpriced.test.ts b/tests/cli-models-unpriced.test.ts index 6069d079..5296d4c1 100644 --- a/tests/cli-models-unpriced.test.ts +++ b/tests/cli-models-unpriced.test.ts @@ -6,17 +6,25 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' function runCli(args: string[], home: string, locale?: string) { + // Agent/CI runners often set FORCE_COLOR=1 even when NO_COLOR=1. Chalk then + // paints table chrome with ESC (U+001B). The hostile-ID assertion below is + // a source-safety check on model IDs, not on chalk headers, so pin color + // off in the child. Do not drop the C0/C1 regex. + const env = { ...process.env } + delete env.FORCE_COLOR + env.NO_COLOR = '1' + env.HOME = home + env.USERPROFILE = home + env.CLAUDE_CONFIG_DIR = join(home, '.claude') + env.CODEBURN_CACHE_DIR = join(home, '.cache', 'codeburn') + env.TZ = 'UTC' + if (locale) { + env.LANG = locale + env.LC_ALL = locale + } return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { cwd: process.cwd(), - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - CLAUDE_CONFIG_DIR: join(home, '.claude'), - CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), - TZ: 'UTC', - ...(locale ? { LANG: locale, LC_ALL: locale } : {}), - }, + env, encoding: 'utf-8', timeout: 30_000, }) diff --git a/tests/fixtures/cache-refresh-lock-wait-probe.mjs b/tests/fixtures/cache-refresh-lock-wait-probe.mjs new file mode 100644 index 00000000..c6ca1758 --- /dev/null +++ b/tests/fixtures/cache-refresh-lock-wait-probe.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs' +import { syncBuiltinESMExports } from 'node:module' +import { basename } from 'node:path' + +// Test-only --import: the real CLI has no "I am parked in lock wait" seam. +// createExclusive uses the named ESM export `open` from 'fs/promises'. +// Patching fs.promises.open alone does not update that live binding (tsx +// already imported the builtin before this probe runs), so the child never +// writes readyPath. syncBuiltinESMExports() republishes the CJS patch to +// the ESM named export the lock code actually calls. +const readyPath = process.env.CODEBURN_LOCK_WAIT_READY +if (readyPath) { + const origOpen = fs.promises.open.bind(fs.promises) + fs.promises.open = async function open(path, flags, mode) { + try { + return await origOpen(path, flags, mode) + } catch (err) { + if ( + err && + /** @type {NodeJS.ErrnoException} */ (err).code === 'EEXIST' && + flags === 'wx' && + typeof path === 'string' && + basename(path) === 'session-refresh.lock' + ) { + try { fs.writeFileSync(readyPath, '') } catch { /* already written */ } + } + throw err + } + } + syncBuiltinESMExports() +} diff --git a/tests/session-cache-status-snapshot.test.ts b/tests/session-cache-status-snapshot.test.ts index 04b59abe..b93df04f 100644 --- a/tests/session-cache-status-snapshot.test.ts +++ b/tests/session-cache-status-snapshot.test.ts @@ -9,15 +9,13 @@ // exercises the locked publication fix for finding B-G1 directly: a slower, // older-corpus write must not clobber a faster, newer one, and two distinct queryKeys // must not evict each other. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { chmod, mkdir, readdir, readFile, rm } from 'fs/promises' import { existsSync } from 'fs' import { createHash } from 'crypto' -import { spawn, spawnSync } from 'child_process' import { tmpdir } from 'os' -import { delimiter, join } from 'path' +import { join } from 'path' -import { acquireCacheRefreshLock } from '../src/cache-refresh-lock.js' import { loadStatusSnapshot, saveStatusSnapshot } from '../src/session-cache.js' let TMP_DIR: string @@ -221,207 +219,6 @@ describe('load-time re-validation mirrors the save gate', () => { }) }) -// Post-merge review of PR #999: a `status --format menubar-json --no-optimize` -// poll that runs while ANOTHER process holds `session-refresh.lock` parses -// read-only and serves a degraded corpus (payload.stale === true), and a later -// in-process parse — the payload builder's own history re-parse, running after -// the holder releases — flips the module-level hydration global back to -// complete before the save point. A save gate consulting only that global -// persists the under-reported payload under the CURRENT corpus fingerprint, -// poisoning every future poll. These cases spawn the real CLI; each one does -// genuine parse work plus a lock-hold window, so they get the same generous -// timeout as the CLI-spawning suites. -vi.setConfig({ testTimeout: 120_000 }) - -const SNAPSHOT_FILE_RE = /^status-snapshot\.[0-9a-f]+\.json$/ -async function snapshotFileNames(cacheDir: string): Promise { - if (!existsSync(cacheDir)) return [] - return (await readdir(cacheDir)).filter(f => SNAPSHOT_FILE_RE.test(f)) -} - -function cliEnv(home: string, extraEnv: Record = {}): NodeJS.ProcessEnv { - return { - ...process.env, - CLAUDE_CONFIG_DIR: join(home, '.claude'), - CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), - HOME: home, USERPROFILE: home, - TZ: 'UTC', - ...extraEnv, - } -} - -function runCli(args: string[], home: string, extraEnv: Record = {}): { status: number | null, stdout: string, stderr: string } { - const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { - cwd: process.cwd(), - env: cliEnv(home, extraEnv), - encoding: 'utf-8', - timeout: 60_000, - }) - return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } -} - -function runCliAsync(args: string[], home: string, extraEnv: Record = {}): Promise<{ status: number | null, stdout: string, stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { - cwd: process.cwd(), - env: cliEnv(home, extraEnv), - }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf-8') }) - child.once('error', reject) - child.once('close', status => { resolve({ status, stdout, stderr }) }) - }) -} - -function userLine(sessionId: string, timestamp: string): string { - return JSON.stringify({ - type: 'user', - sessionId, - timestamp, - message: { role: 'user', content: 'do the thing' }, - }) -} - -function assistantLine(sessionId: string, timestamp: string, messageId: string): string { - return JSON.stringify({ - type: 'assistant', - sessionId, - timestamp, - message: { - id: messageId, - type: 'message', - role: 'assistant', - model: 'claude-sonnet-4-5', - content: [{ type: 'text', text: 'done' }], - usage: { input_tokens: 500, output_tokens: 50 }, - }, - }) -} - -const delay = (ms: number): Promise => new Promise(resolve => { setTimeout(resolve, ms) }) - -describe('degraded read-only parse is never persisted as a status snapshot', () => { - it('writes no snapshot for a lock-degraded poll, then resumes persisting on the clean pass', async () => { - const home = await mkdtemp(join(tmpdir(), 'codeburn-snapshot-degraded-')) - const cacheDir = join(home, '.cache', 'codeburn') - try { - // Two Claude config roots: the query below is scoped to one of them via - // --claude-config-source. The scoped path is what makes the PR #999 - // sequence reachable in a one-shot process: the payload builder captures - // the hydration verdict right after the (degraded) primary parse, then - // runs its OWN history re-parse over a wider range — a second, real - // parse that re-acquires the lock once the holder releases and flips the - // module-level hydration global back to complete before the save point. - const work = join(home, 'claude-work') - const personal = join(home, 'claude-personal') - await mkdir(join(work, 'projects', 'app'), { recursive: true }) - await mkdir(join(personal, 'projects', 'app'), { recursive: true }) - - // Two hours back, clamped inside the current UTC day (cliEnv pins - // TZ=UTC), so every session falls inside the 'today' query. - const now = new Date() - const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) - const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z') - - await writeFile( - join(work, 'projects', 'app', 'w1.jsonl'), - [userLine('w1', ts(0)), assistantLine('w1', ts(60_000), 'msg-w1')].join('\n') + '\n', - ) - await writeFile( - join(personal, 'projects', 'app', 'p1.jsonl'), - [userLine('p1', ts(30_000)), assistantLine('p1', ts(90_000), 'msg-p1')].join('\n') + '\n', - ) - const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(delimiter) } - - // Warm the session cache through the DEFAULT optimize path: it parses - // and persists the corpus but never reads or writes the status snapshot - // (main.ts: useSnapshot = !optimize). Also discovers the config-source - // id the scoped queries below select. - const warmStart = Date.now() - const warm = runCli(['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all'], home, env) - const warmElapsedMs = Date.now() - warmStart - expect(warm.status, `stderr: ${warm.stderr}`).toBe(0) - const warmPayload = JSON.parse(warm.stdout) as { - current: { calls: number } - claudeConfigs: { options: Array<{ id: string, label: string }> } - } - expect(warmPayload.current.calls).toBe(2) - const workSourceId = warmPayload.claudeConfigs.options.find(o => o.label === 'claude-work')?.id - expect(workSourceId).toBeTruthy() - expect(await snapshotFileNames(cacheDir)).toEqual([]) - - // New activity in the SELECTED root that the warm cache has never seen. - // A read-only parse has no cache entry for it and must skip it, - // under-reporting the totals. - await writeFile( - join(work, 'projects', 'app', 'w2.jsonl'), - [userLine('w2', ts(120_000)), assistantLine('w2', ts(180_000), 'msg-w2')].join('\n') + '\n', - ) - - const args = [ - 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', - '--claude-config-source', workSourceId!, - '--no-optimize', - ] - - // A live, heartbeating owner holding the refresh lock, exactly as in - // cache-refresh-lock.test.ts: the pid answers signal 0 and the mtime - // stays fresh, so the child's primary parse can neither acquire nor - // take over — it parks in the wait loop. - const held = await acquireCacheRefreshLock({ cacheDir }) - expect(held.outcome).toBe('acquired') - if (held.outcome !== 'acquired') return - - let degraded - try { - const running = runCliAsync(args, home, env) - // Release only once the child is certainly parked in its lock wait: - // the warm run just did the same startup (tsx boot, pricing load, - // corpus fingerprint) the child repeats before it ever touches the - // lock, so its acquire attempt lands well inside this window. - // Releasing earlier would let that FIRST acquire succeed and turn - // this into a clean run; releasing is what reports - // 'completed-by-other' and sends the primary parse down the read-only - // path while leaving the lock free for the payload builder's later - // history re-parse — the exact PR #999 sequence. - await delay(warmElapsedMs + 1_500) - await held.handle.release() - degraded = await running - } finally { - await held.handle.release() - } - - expect(degraded.status, `stderr: ${degraded.stderr}`).toBe(0) - const degradedPayload = JSON.parse(degraded.stdout) as { stale?: boolean, current: { calls: number } } - // The primary parse went read-only behind the held lock and served the - // warm cache: w2 is missing from the totals and the payload says so. - expect(degradedPayload.stale).toBe(true) - expect(degradedPayload.current.calls).toBe(1) - // The gate: no snapshot may be persisted from this degraded payload, - // even though the history re-parse after the release flipped the - // hydration global back to complete before the save point. - expect(await snapshotFileNames(cacheDir)).toEqual([]) - - // The gate reopens: the identical query on a clean pass recomputes and - // persists a complete snapshot. - const clean = runCli(args, home, env) - expect(clean.status, `stderr: ${clean.stderr}`).toBe(0) - const cleanPayload = JSON.parse(clean.stdout) as { stale?: boolean, current: { calls: number } } - expect(cleanPayload.stale).toBeUndefined() - expect(cleanPayload.current.calls).toBe(2) - - const snapshots = await snapshotFileNames(cacheDir) - expect(snapshots).toHaveLength(1) - const record = JSON.parse(await readFile(join(cacheDir, snapshots[0]!), 'utf-8')) as { - payload: { stale?: boolean, current: { calls: number } } - } - expect(record.payload.stale).toBeUndefined() - expect(record.payload.current.calls).toBe(2) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) -}) +// The cross-process lock-degraded snapshot gate (PR #999: stale payload must +// not persist, then a clean pass writes the snapshot) lives in +// cache-refresh-lock-status-snapshot.test.ts so it runs serially via test:locks.