From 99c6012e4f480fc8f09c0ef59c3cad5e809db6ed Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 13 Aug 2026 19:18:29 +0800 Subject: [PATCH 01/21] feat(review): run-session ledger and cross-session agent evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for resuming an interrupted review run. fetch-pr now records its session id in a run ledger beside the prompt records and stamps the plan with a sha256 of the captured diff bytes; a new readRunTranscripts reads the harness transcripts of every session the ledger names (the current session's contract is unchanged), so coverage, retirement, the layer-audit gate and the cost ledger can credit an earlier attempt's certified work. Coverage counts such agents as recoveredAgents and discloses the continuity; the cost ledger folds the earlier sessions' main loop and agents into the run's totals and reports priorSessions. A run that never resumes sees no behavior change: with no ledger entries every reader reduces to its previous single-session read. Fabricated ledger entries grant nothing — they only name directories under the harness's own subagents tree, and credit still requires the existing content-shaped pairing (verbatim-delivered prompt, opened brief, diff reads). --- .../commands/review/check-coverage.test.ts | 84 ++++++ .../src/commands/review/cost-ledger.test.ts | 109 ++++++++ .../cli/src/commands/review/cost-ledger.ts | 101 +++++-- .../cli/src/commands/review/fetch-pr.test.ts | 72 +++++ packages/cli/src/commands/review/fetch-pr.ts | 19 ++ .../cli/src/commands/review/lib/coverage.ts | 70 ++++- .../commands/review/lib/layer-audit-gate.ts | 7 +- .../commands/review/lib/retirement.test.ts | 121 +++++++++ .../cli/src/commands/review/lib/retirement.ts | 8 +- .../commands/review/lib/run-ledger.test.ts | 198 ++++++++++++++ .../cli/src/commands/review/lib/run-ledger.ts | 251 ++++++++++++++++++ .../commands/review/lib/transcripts.test.ts | 148 ++++++++++- .../src/commands/review/lib/transcripts.ts | 90 +++++++ 13 files changed, 1247 insertions(+), 31 deletions(-) create mode 100644 packages/cli/src/commands/review/lib/run-ledger.test.ts create mode 100644 packages/cli/src/commands/review/lib/run-ledger.ts diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index f393950035c..eb471b75a56 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -25,6 +25,7 @@ import { mkdirSync, utimesSync, readdirSync, + renameSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -2158,3 +2159,86 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', () expect(r.gaps[0].subject).toBe('reverse audit'); }); }); + +describe('coverage — a resumed run credits the prior attempt through the ledger', () => { + // The run ledger `fetch-pr` writes: S0 is the interrupted attempt, S1 the + // resumed continuation this suite's ENV runs as. Entries carry a current + // atMs, which sits inside the epoch fence of the backdated plan. + function ledger(planPath: string, ...ids: string[]): void { + const d = promptRecordDir(planPath); + mkdirSync(d, { recursive: true }); + writeFileSync( + join(d, 'run-sessions.json'), + JSON.stringify(ids.map((id) => ({ sessionId: id, atMs: Date.now() }))), + ); + } + + /** Re-home a transcript written by `transcript()` into another session. */ + function moveToSession(id: string, session: string): void { + mkdirSync(join(dir, 'subagents', session), { recursive: true }); + renameSync( + join(dir, 'subagents', 'S1', `agent-${id}.jsonl`), + join(dir, 'subagents', session, `agent-${id}.jsonl`), + ); + } + + it('passes 3D on work the interrupted attempt completed, and discloses it', () => { + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1', good(1), { calls: 3 }); + moveToSession('a1', 'S0'); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.ok).toBe(true); + expect(r.coveredChunks).toEqual([1, 2]); + expect(r.recoveredAgents).toBeGreaterThanOrEqual(1); + expect( + r.disclosures.some( + (d) => d.subject === 'review continuity' && /resumed/.test(d.reason), + ), + ).toBe(true); + }); + + it('sees nothing from a prior session the ledger never recorded', () => { + // The orphan-invisibility guard: no ledger entry, no evidence — a + // fabricated directory cannot vouch for itself. + const p = plan(); + transcript('a1', good(1), { calls: 3 }); + moveToSession('a1', 'S0'); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.ok).toBe(false); + expect(r.missingChunks).toEqual([1]); + expect(r.recoveredAgents).toBe(0); + }); + + it("lets a compliant relaunch supersede the prior attempt's failure", () => { + // Attempt 1's chunk-1 agent idled before the crash; the resumed run + // relaunched it properly. The prior failure must not pin `ok` false. + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1', good(1), { calls: 0 }); + moveToSession('a1', 'S0'); + transcript('a1b', good(1), { calls: 3 }); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.ok).toBe(true); + expect(r.idleAgents).toEqual([]); + // The idle prior record certifies nothing, so it is not "recovered". + expect(r.recoveredAgents).toBe(0); + }); + + it('emits no continuity disclosure on a run that never resumed', () => { + transcript('a1', good(1), { calls: 3 }); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(plan(), ENV); + expect(r.recoveredAgents).toBe(0); + expect(r.disclosures.some((d) => d.subject === 'review continuity')).toBe( + false, + ); + }); +}); diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index 170b9732771..e2174715452 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -1338,3 +1338,112 @@ describe('cost-ledger command boundary — informational, never a failure', () = expect(existsSync(join(blocked, 'ledger.json'))).toBe(false); }); }); + +describe('cost-ledger — a resumed run bills the whole review', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + function fixture(): { + plan: string; + env: NodeJS.ProcessEnv; + project: string; + } { + const project = mkdtempSync(join(tmpdir(), 'ledger-resume-')); + dirs.push(project); + mkdirSync(join(project, 'chats'), { recursive: true }); + mkdirSync(join(project, 'subagents', SESSION), { recursive: true }); + writeFileSync( + join(project, 'chats', `${SESSION}.jsonl`), + event('2026-08-03T10:10:00Z', { input: 500, output: 50 }), + ); + const plan = join(project, 'plan.json'); + writeFileSync( + plan, + JSON.stringify({ + diffPathAbsolute: join(project, 'diff.txt'), + diffLines: 10, + chunks: [{ id: 1, startLine: 1, endLine: 10 }], + }), + ); + const start = new Date('2026-08-03T10:00:00Z'); + utimesSync(plan, start, start); + return { + plan, + project, + env: { + QWEN_CODE_PROJECT_DIR: project, + QWEN_CODE_SESSION_ID: SESSION, + } as NodeJS.ProcessEnv, + }; + } + + /** The ledger `fetch-pr` writes, naming the interrupted attempt S0. */ + function runLedger(plan: string, project: string): void { + const d = join(project, 'plan-prompts'); + mkdirSync(d, { recursive: true }); + writeFileSync( + join(d, 'run-sessions.json'), + JSON.stringify([ + { sessionId: 'S0', atMs: Date.parse('2026-08-03T10:00:30Z') }, + { sessionId: SESSION, atMs: Date.parse('2026-08-03T10:09:00Z') }, + ]), + ); + } + + it("folds the interrupted attempt's main loop and agents into the totals", () => { + const { plan, env, project } = fixture(); + runLedger(plan, project); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:01:00Z', { input: 1_000, output: 100 }), + ); + mkdirSync(join(project, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(project, 'subagents', 'S0', 'agent-a0.jsonl'), + [ + userRecord('You are review agent `2` — Agent 2: Security.'), + event('2026-08-03T10:02:00Z', { input: 2_000, output: 200 }), + ].join('\n'), + ); + + const ledger = computeLedger(plan, env); + expect(ledger.priorSessions).toBe(1); + expect(ledger.main?.calls).toBe(2); + expect(ledger.main?.inputTokens).toBe(1_500); + expect(ledger.agents).toHaveLength(1); + expect(ledger.totals.inputTokens).toBe(3_500); + }); + + it('reports zero prior sessions without a ledger — and reads nothing extra', () => { + const { plan, env, project } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:01:00Z', { input: 1_000, output: 100 }), + ); + + const ledger = computeLedger(plan, env); + expect(ledger.priorSessions).toBe(0); + expect(ledger.main?.calls).toBe(1); + expect(ledger.totals.inputTokens).toBe(500); + }); + + it('counts a prior session with agents but a lost chat file', () => { + const { plan, env, project } = fixture(); + runLedger(plan, project); + mkdirSync(join(project, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(project, 'subagents', 'S0', 'agent-a0.jsonl'), + [ + userRecord('You are review agent `2` — Agent 2: Security.'), + event('2026-08-03T10:02:00Z', { input: 2_000, output: 200 }), + ].join('\n'), + ); + + const ledger = computeLedger(plan, env); + expect(ledger.priorSessions).toBe(1); + expect(ledger.agents).toHaveLength(1); + expect(ledger.totals.inputTokens).toBe(2_500); + }); +}); diff --git a/packages/cli/src/commands/review/cost-ledger.ts b/packages/cli/src/commands/review/cost-ledger.ts index 7c4d5c6f4c4..a8804fe8cef 100644 --- a/packages/cli/src/commands/review/cost-ledger.ts +++ b/packages/cli/src/commands/review/cost-ledger.ts @@ -43,6 +43,7 @@ import { textOf, } from './lib/transcripts.js'; import { labelFromIdentityLine } from './lib/agent-identity.js'; +import { priorSessionIds } from './lib/run-ledger.js'; interface CostLedgerArgs { plan: string; @@ -67,6 +68,14 @@ interface Ledger { totals: Omit & { wallSeconds: number }; main: StreamCost | null; agents: StreamCost[]; + /** + * How many EARLIER sessions of this run (a resumed review) contributed + * streams. Zero on a run that never resumed; the field then reads as "this + * ledger is one session's". The interrupted attempt's cost is part of the + * review's cost — a resume that hid it would report a review as cheaper + * than it was. + */ + priorSessions: number; } interface UsageEvent { @@ -316,9 +325,6 @@ export function computeLedger( `${(err as Error).message}`, ); } - const main = - mainEvents.length > 0 ? foldEvents('main', 'main loop', mainEvents) : null; - let files: string[]; try { files = listAgentTranscriptFiles(dir); @@ -339,29 +345,63 @@ export function computeLedger( const agents: StreamCost[] = []; const agentEvents: UsageEvent[] = []; - for (const f of files) { - const full = join(dir, f); - let mtimeMs: number; + const readAgentDir = (agentDir: string, names: string[]): number => { + let streams = 0; + for (const f of names) { + const full = join(agentDir, f); + let mtimeMs: number; + try { + mtimeMs = statSync(full).mtimeMs; + } catch { + continue; // Gone between listing and stat. + } + // The transcript dir is session-scoped and never pruned: files from + // earlier reviews this session predate the floor, and a file whose last + // write predates it cannot hold an above-floor record — the same + // membership test `readTranscripts` applies. Skip it without opening. + if (mtimeMs < floorMs) continue; + let read: { events: UsageEvent[]; launch: string }; + try { + read = readUsage(full, floorMs); + } catch { + continue; // This agent's record is lost; the rest still count. + } + if (read.events.length === 0) continue; + const id = f.replace(/^agent-/, '').replace(/\.jsonl$/, ''); + agents.push(foldEvents(id, labelOf(read.launch, id), read.events)); + agentEvents.push(...read.events); + streams++; + } + return streams; + }; + readAgentDir(dir, files); + + // Earlier sessions of THIS run (a resumed review): their cost is part of + // the review's cost. Unlike the current session, a prior session whose + // records cannot be read only makes the ledger a floor, not a fabrication — + // so unreadable prior state is skipped, never fatal, and the count of + // sessions that did contribute is reported. + const priorMainEvents: UsageEvent[] = []; + let priorSessions = 0; + for (const id of priorSessionIds(planPath, env)) { + let contributed = 0; try { - mtimeMs = statSync(full).mtimeMs; + const events = readUsage( + join(projectDir, 'chats', `${id}.jsonl`), + floorMs, + ).events; + priorMainEvents.push(...events); + contributed += events.length; } catch { - continue; // Gone between listing and stat. + // The prior attempt's chat is lost; its agents may still count. } - // The transcript dir is session-scoped and never pruned: files from - // earlier reviews this session predate the floor, and a file whose last - // write predates it cannot hold an above-floor record — the same - // membership test `readTranscripts` applies. Skip it without opening. - if (mtimeMs < floorMs) continue; - let read: { events: UsageEvent[]; launch: string }; + const priorDir = join(projectDir, 'subagents', id); try { - read = readUsage(full, floorMs); + contributed += readAgentDir(priorDir, listAgentTranscriptFiles(priorDir)); } catch { - continue; // This agent's record is lost; the rest still count. + // No prior agent dir is a real state (it died before launching any). } - if (read.events.length === 0) continue; - const id = f.replace(/^agent-/, '').replace(/\.jsonl$/, ''); - agents.push(foldEvents(id, labelOf(read.launch, id), read.events)); - agentEvents.push(...read.events); + if (contributed > 0) priorSessions++; } agents.sort((a, b) => b.inputTokens - a.inputTokens); @@ -380,11 +420,18 @@ export function computeLedger( ); } + // One `main` row for the run: a resumed run's orchestrator turns span two + // chat files, but they are the same loop doing the same job. Folded after + // the emptiness check above, which is deliberately about the CURRENT + // session only — prior events must not vouch for a broken current chat. + const allMainEvents = [...priorMainEvents, ...mainEvents]; + const main = foldEvents('main', 'main loop', allMainEvents); + // The same events the per-stream rows fold, folded once more — one // accumulator, so a new usage counter cannot land in the rows and miss the // headline. const totals = foldEvents('totals', 'totals', [ - ...mainEvents, + ...allMainEvents, ...agentEvents, ]); const wallSeconds = @@ -398,7 +445,12 @@ export function computeLedger( : 0; const { id: _i, label: _l, ...totalsRest } = totals; - return { totals: { ...totalsRest, wallSeconds }, main, agents }; + return { + totals: { ...totalsRest, wallSeconds }, + main, + agents, + priorSessions, + }; } /** The printed block: one summary line, the main loop, the top consumers. */ @@ -420,6 +472,11 @@ export function renderLedger(ledger: Ledger): string { `${human(m.outputTokens)} out`, ); } + if (ledger.priorSessions > 0) { + lines.push( + ` resumed run: totals include ${plural(ledger.priorSessions, 'earlier session')} of this review`, + ); + } if (ledger.agents.length > 0) { // Equal labels fold into one row marked (×N): a relaunched agent keeps // its label, and verify shards deliberately share one — the marker reads diff --git a/packages/cli/src/commands/review/fetch-pr.test.ts b/packages/cli/src/commands/review/fetch-pr.test.ts index 42f3e0c8be9..5175b85e773 100644 --- a/packages/cli/src/commands/review/fetch-pr.test.ts +++ b/packages/cli/src/commands/review/fetch-pr.test.ts @@ -624,3 +624,75 @@ describe('countDiffChangedLines', () => { expect(countDiffChangedLines(d)).toBe(4); }); }); + +describe('fetch-pr diff identity (diffSha256)', () => { + beforeEach(() => { + vi.clearAllMocks(); + producerMocks.readFileSync.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + producerMocks.git.mockImplementation((...args: string[]) => + args[0] === 'rev-parse' ? 'f00df00df00d' : '', + ); + producerMocks.gh.mockReturnValue( + JSON.stringify({ + headRefName: 'feat/x', + headRefOid: 'f00df00df00d', + baseRefName: 'main', + additions: 1, + deletions: 0, + changedFiles: 1, + isCrossRepository: false, + body: '', + }), + ); + }); + + async function reportFor() { + const handler = fetchPrCommand.handler; + if (!handler) throw new Error('fetch-pr handler missing'); + await handler({ + _: [], + $0: 'qwen', + pr_number: '42', + owner_repo: 'acme/widgets', + remote: 'origin', + out: '/tmp/fetch-report.json', + maxChunkLines: 400, + } as unknown as Parameters[0]); + const call = producerMocks.writeFileSync.mock.calls.find( + ([path]) => path === '/tmp/fetch-report.json', + ); + if (!call) throw new Error('report was not written'); + return JSON.parse(String(call[1])); + } + + it('hashes the captured diff bytes — the resume check compares against this', async () => { + const diff = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n+x\n'; + const { resolveMergeBase } = await import('./lib/merge-base.js'); + const { gitRaw } = await import('./lib/git.js'); + vi.mocked(resolveMergeBase).mockReturnValue({ + sha: 'base123', + baseFetchFailed: false, + }); + vi.mocked(gitRaw).mockImplementation((...args: string[]) => + args.includes('diff') ? Buffer.from(diff) : Buffer.from(''), + ); + + const report = await reportFor(); + const { createHash } = await import('node:crypto'); + expect(report.diffSha256).toBe( + createHash('sha256').update(Buffer.from(diff)).digest('hex'), + ); + }); + + it('is null when no diff was captured', async () => { + const { resolveMergeBase } = await import('./lib/merge-base.js'); + vi.mocked(resolveMergeBase).mockReturnValue({ + sha: null, + baseFetchFailed: false, + }); + const report = await reportFor(); + expect(report.diffSha256).toBeNull(); + }); +}); diff --git a/packages/cli/src/commands/review/fetch-pr.ts b/packages/cli/src/commands/review/fetch-pr.ts index a480466e242..67c90face92 100644 --- a/packages/cli/src/commands/review/fetch-pr.ts +++ b/packages/cli/src/commands/review/fetch-pr.ts @@ -27,6 +27,7 @@ import type { CommandModule } from 'yargs'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; @@ -56,6 +57,7 @@ import { import { resolveMergeBase, type GitProbe } from './lib/merge-base.js'; import { operatorReviewSettings } from './lib/review-settings.js'; import { hasReviewDeadline } from './lib/deadline.js'; +import { appendRunSession } from './lib/run-ledger.js'; interface PrMetadata { headRefName: string; @@ -131,6 +133,15 @@ type FetchPrResult = PlanReport & { diffPath: string | null; /** Absolute path — `read_file` rejects relative paths. Agents use this. */ diffPathAbsolute: string | null; + /** + * SHA-256 of the captured diff's raw bytes — the identity of WHAT this run + * reviews, hashed from the same buffer the diff file was written from (the + * `diffHashOf` discipline: one read, no TOCTOU window). `--resume` compares + * it against the diff file on disk: a mismatch means the input changed, and + * changed input re-runs — the checkpoint key is content, never a path or a + * timestamp. Null when no diff was captured. + */ + diffSha256: string | null; /** * True when the PR description contains Han characters — the author writes * Chinese. `compose-review` reads it from this report (its `planPath`) and @@ -271,6 +282,7 @@ async function runFetchPr(args: FetchPrArgs): Promise { const diffRel = tmpFile(`pr-${prNumber}`, 'diff.txt'); let diffPath: string | null = null; let diffPathAbsolute: string | null = null; + let diffSha256: string | null = null; let diffText = ''; if (mergeBaseSha) { try { @@ -287,6 +299,7 @@ async function runFetchPr(args: FetchPrArgs): Promise { diffText = buf.toString('utf8'); diffPath = diffRel; diffPathAbsolute = resolve(diffRel); + diffSha256 = createHash('sha256').update(buf).digest('hex'); } catch (err) { writeStderrLine(`Failed to capture diff: ${(err as Error).message}`); } @@ -311,6 +324,7 @@ async function runFetchPr(args: FetchPrArgs): Promise { ); diffPath = null; diffPathAbsolute = null; + diffSha256 = null; plan = buildDiffPlan('', args.maxChunkLines); } @@ -431,6 +445,7 @@ async function runFetchPr(args: FetchPrArgs): Promise { baseFetchFailed, diffPath, diffPathAbsolute, + diffSha256, prDescriptionHasHan: /\p{Script=Han}/u.test(meta.body ?? ''), ...buildPlanReport(plan, (path) => fileLineCount(fetchedSha, path), { operatorRoundCap: operatorReviewSettings().reverseAuditRounds, @@ -440,6 +455,10 @@ async function runFetchPr(args: FetchPrArgs): Promise { }; writeFileSync(out, stringifyPlanReport(result), 'utf8'); + // Record this session against the plan just written: a later `--resume` + // reads the ledger to find this attempt's transcripts. After the plan + // write, so the entry sits inside the run-epoch fence it is read through. + appendRunSession(out); writeStdoutLine(`Wrote fetch-pr report to ${out}`); if (diffPath) writeStdoutLine(`Wrote review diff to ${diffPath}`); // Surface diff stats to stderr so a human running the command interactively diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 9220ce78fc0..93550801219 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -54,7 +54,7 @@ import { readFileSync, statSync } from 'node:fs'; import { - readTranscripts, + readRunTranscripts, wasGivenTheDiff, TranscriptsUnavailableError, type AgentRecord, @@ -82,6 +82,14 @@ export interface CoverageFromTranscripts { ok: boolean; /** How many subagent transcripts the harness wrote for this run. */ agents: number; + /** + * Agents whose certified work came from an EARLIER attempt's session — a + * resumed run crediting the interrupted attempt's evidence. Zero on any run + * that never resumed. Counted only for records that clear the same bar as + * everyone else (verbatim-delivered CLI prompt plus an opened brief or an + * opened diff); reading the prior directory grants nothing by itself. + */ + recoveredAgents: number; /** * Chunk agents launched with a prompt that never named the diff. * @@ -390,7 +398,16 @@ export function coverageFromTranscripts( env: NodeJS.ProcessEnv = process.env, ): CoverageFromTranscripts { const { plan, mtimeMs } = readPlan(planPath); - const records = readTranscripts(mtimeMs, env, plan.diffPathAbsolute); + // The RUN's transcripts, not the session's: a resumed run (`--resume`) + // continues in a new session, and the interrupted attempt's evidence lives + // under the session id the run ledger recorded. Same fence (the plan's + // mtime), which a resume deliberately leaves untouched. + const records = readRunTranscripts( + planPath, + mtimeMs, + env, + plan.diffPathAbsolute, + ); const built = readRecordedPrompts(planPath); const blindAgents: string[] = []; @@ -958,6 +975,45 @@ export function coverageFromTranscripts( (id) => !covered.has(id) && !uncoverable.has(id), ); + // Prior-attempt records that clear the SAME certification bar as a live + // launch — the resumed run's recovered work. The bar is deliberately the + // pairing predicates above, not "the file existed": a fabricated ledger + // entry can point the reader at a directory, but only a harness transcript + // whose launch verbatim-contains a CLI-built prompt and shows the brief or + // the diff actually opened earns a count here. + const certifies = (r: AgentRecord): boolean => { + const c = assignedChunk(r); + if (c !== null) { + const b = builtOf(`chunk-${c}`); + return ( + b !== undefined && + wasDeliveredVerbatim(r.launchPrompt, b) && + r.diffToolCalls > 0 + ); + } + for (const key of built.keys()) { + const b = builtOf(key); + if (b === undefined) continue; + if (wasDeliveredVerbatim(r.launchPrompt, b) && openedBriefOf(r, key)) { + return true; + } + } + return false; + }; + const recoveredAgents = records.filter( + (r) => r.fromPriorSession && certifies(r), + ).length; + if (recoveredAgents > 0) { + disclose( + 'review continuity', + `resumed — ${recoveredAgents} agent result(s) recovered from an interrupted earlier attempt`, + { + subjectZh: '评审续跑', + reasonZh: `本次为续跑 — 复用了上一次中断运行的 ${recoveredAgents} 个 agent 结果`, + }, + ); + } + return { ok: blindAgents.length === 0 && @@ -972,6 +1028,7 @@ export function coverageFromTranscripts( uncoverable.size === 0 && missingChunks.length === 0, agents: records.length, + recoveredAgents, blindAgents, idleAgents, unopenedAgents, @@ -1337,7 +1394,14 @@ export function verificationGaps( env: NodeJS.ProcessEnv = process.env, ): VerificationReport { const { plan, mtimeMs } = readPlan(planPath); - const records = readTranscripts(mtimeMs, env, plan.diffPathAbsolute); + // Run-scoped for the same reason as `coverageFromTranscripts`: a resumed + // run's Step 4/5 evidence may sit in the interrupted attempt's session dir. + const records = readRunTranscripts( + planPath, + mtimeMs, + env, + plan.diffPathAbsolute, + ); const built = readRecordedPrompts(planPath); const gaps: VerificationReport['gaps'] = []; const remediation: string[] = []; diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.ts index ec66dd02fef..75e9b86e912 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.ts @@ -55,7 +55,7 @@ // floor. import { statSync, readFileSync } from 'node:fs'; -import { readTranscripts } from './transcripts.js'; +import { readRunTranscripts } from './transcripts.js'; import { bakedRanges, openedTheTerritory } from './retirement.js'; import { repositoryContextOf, @@ -95,8 +95,9 @@ function readReverseAuditReturns( ): AuditorReturns { try { const since = statSync(planPath).mtimeMs; - const auditors = readTranscripts(since, env, diffPath).filter((t) => - t.launchPrompt.includes(REVERSE_AUDIT_IDENTITY), + // Run-scoped: a resumed run's earlier auditors ran in a different session. + const auditors = readRunTranscripts(planPath, since, env, diffPath).filter( + (t) => t.launchPrompt.includes(REVERSE_AUDIT_IDENTITY), ); const corroborated = auditors .filter( diff --git a/packages/cli/src/commands/review/lib/retirement.test.ts b/packages/cli/src/commands/review/lib/retirement.test.ts index fac20898010..2b1a8c0f3ff 100644 --- a/packages/cli/src/commands/review/lib/retirement.test.ts +++ b/packages/cli/src/commands/review/lib/retirement.test.ts @@ -1683,3 +1683,124 @@ describe('scheduleReverseAuditRound — the scheduler on its own', () => { expect(r3.converged).toBe(false); }); }); + +describe('scheduleReverseAuditRound — a resumed run reads the prior attempt', () => { + let dir: string; + let plan: string; + let diff: string; + let seq = 0; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'retirement-resume-')); + plan = join(dir, 'plan.json'); + writeFileSync(plan, '{}'); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + diff = join(dir, 'diff.txt'); + process.env['QWEN_CODE_PROJECT_DIR'] = dir; + process.env['QWEN_CODE_SESSION_ID'] = 'S1'; + mkdirSync(join(dir, 'subagents', 'S1'), { recursive: true }); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + }); + + afterEach(() => { + delete process.env['QWEN_CODE_PROJECT_DIR']; + delete process.env['QWEN_CODE_SESSION_ID']; + rmSync(dir, { recursive: true, force: true }); + }); + + function record(round: number, chunk: number, body: string): string { + const prompt = `reverse-audit ${body}`; + recordPrompt( + plan, + `reverse-audit--chunk-${chunk}--round-${round}--abc123`, + prompt, + ); + return prompt; + } + + /** A dry-receipt transcript, written into the named session's dir. */ + function transcriptIn(session: string, launchPrompt: string): void { + const id = `aud-${++seq}`; + const base = { + agentId: id, + agentName: 'general-purpose', + sessionId: session, + }; + const lines = [ + JSON.stringify({ + ...base, + type: 'user', + message: { role: 'user', parts: [{ text: launchPrompt }] }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: diff, offset: 0, limit: 100 }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'diff bytes' }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { role: 'model', parts: [{ text: DRY }] }, + }), + ]; + writeFileSync( + join(dir, 'subagents', session, `agent-${id}.jsonl`), + lines.join('\n') + '\n', + ); + } + + function ledger(...ids: string[]): void { + const d = promptRecordDir(plan); + mkdirSync(d, { recursive: true }); + writeFileSync( + join(d, 'run-sessions.json'), + JSON.stringify(ids.map((id) => ({ sessionId: id, atMs: Date.now() }))), + ); + } + + it('retires a chunk on dry receipts the interrupted attempt earned', () => { + ledger('S0', 'S1'); + for (const r of [1, 2]) { + transcriptIn('S0', record(r, 13, `chunk 13 round ${r} territory walk`)); + } + const r3 = scheduleReverseAuditRound(plan, [13], 3, process.env, diff); + expect(r3.due).toEqual([]); + expect(r3.skipped.map((s) => s.chunkId)).toEqual([13]); + expect(r3.converged).toBe(true); + }); + + it('keeps every chunk hot when no ledger names the prior session', () => { + for (const r of [1, 2]) { + transcriptIn('S0', record(r, 13, `chunk 13 round ${r} territory walk`)); + } + const r3 = scheduleReverseAuditRound(plan, [13], 3, process.env, diff); + expect(r3.due).toEqual([13]); + expect(r3.skipped).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/review/lib/retirement.ts b/packages/cli/src/commands/review/lib/retirement.ts index 69e95df7dd6..6e91af026a0 100644 --- a/packages/cli/src/commands/review/lib/retirement.ts +++ b/packages/cli/src/commands/review/lib/retirement.ts @@ -42,7 +42,7 @@ import { readFileSync, statSync } from 'node:fs'; import { resolve, sep } from 'node:path'; -import { readTranscripts, type AgentRecord } from './transcripts.js'; +import { readRunTranscripts, type AgentRecord } from './transcripts.js'; import { REVERSE_AUDIT_EXAMPLE_RECEIPT } from './agent-briefs.js'; import { INLINE_LAYER_WALKED_RE, @@ -661,7 +661,11 @@ export function scheduleReverseAuditRound( // retries with the least time left. A record older than the plan is the // dead attempt's, and reads as absent. const since = statSync(planPath).mtimeMs; - const transcripts = readTranscripts(since, env, diffPath); + // Run-scoped: a resumed run's earlier rounds ran in a different session, + // and their dry receipts are exactly what lets the continuation retire + // territory instead of re-auditing it. The fence stays the plan's mtime, + // which a resume deliberately leaves untouched. + const transcripts = readRunTranscripts(planPath, since, env, diffPath); const built = readRecordedPrompts(planPath, since); // The prior-round records: one per (chunk, round) prompt this CLI built. diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts new file mode 100644 index 00000000000..0347e455be0 --- /dev/null +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// The session ledger and resume marker, in isolation. The properties under +// test are the failure directions: an entry the ledger cannot vouch for +// (malformed, stale, traversal-shaped) must read as ABSENT — invisible +// evidence re-runs work, fabricated evidence must never mint credit — and a +// fresh run of the same PR (plan rewritten, epoch advanced) must start with +// an empty history. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync, + readFileSync, + utimesSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + appendRunSession, + priorSessionIds, + runSessionsPath, + readResumeMarker, + recordResume, + recordRestart, + resumeMarkerPath, + RESUME_MAX, +} from './run-ledger.js'; + +let root: string; +let plan: string; + +const envOf = (sessionId: string): NodeJS.ProcessEnv => ({ + QWEN_CODE_PROJECT_DIR: root, + QWEN_CODE_SESSION_ID: sessionId, +}); + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'run-ledger-'))); + plan = join(root, 'qwen-review-pr-7-fetch.json'); + writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('appendRunSession / priorSessionIds', () => { + it('records a session and surfaces it to a LATER session as prior', () => { + appendRunSession(plan, envOf('S1')); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('excludes the current session from its own priors', () => { + appendRunSession(plan, envOf('S1')); + expect(priorSessionIds(plan, envOf('S1'))).toEqual([]); + }); + + it('appends each session once, preserving order', () => { + appendRunSession(plan, envOf('S1')); + appendRunSession(plan, envOf('S1')); + appendRunSession(plan, envOf('S2')); + expect(priorSessionIds(plan, envOf('S3'))).toEqual(['S1', 'S2']); + }); + + it('refuses a session id that could traverse out of subagents/', () => { + appendRunSession(plan, envOf('../evil')); + appendRunSession(plan, envOf('a/b')); + appendRunSession(plan, envOf('')); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('drops a traversal-shaped id on READ even when the file carries it', () => { + // The ledger file itself is inside the record dir the orchestrator can + // reach; a hand-written entry must still fail the character-set gate. + appendRunSession(plan, envOf('S1')); + const raw = JSON.parse( + readFileSync(runSessionsPath(plan), 'utf8'), + ) as Array>; + raw.push({ sessionId: '../../etc', atMs: Date.now() }); + writeFileSync(runSessionsPath(plan), JSON.stringify(raw)); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('drops entries older than the plan — a previous review of the same PR', () => { + appendRunSession(plan, envOf('S0'), Date.now() - 60_000); + // Rewriting the plan advances the run epoch past the stale entry. + writeFileSync(plan, JSON.stringify({ diffLines: 2, chunks: [] })); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('keeps entries when the plan is untouched — the resume case', () => { + appendRunSession(plan, envOf('S1')); + // Simulate time passing without a plan rewrite: entries stay visible. + const past = new Date(Date.now() - 3600_000); + utimesSync(plan, past, past); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('reads a corrupt ledger as empty', () => { + appendRunSession(plan, envOf('S1')); + writeFileSync(runSessionsPath(plan), '{not json'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('reads a non-array ledger as empty', () => { + appendRunSession(plan, envOf('S1')); + writeFileSync(runSessionsPath(plan), JSON.stringify({ sessionId: 'S1' })); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('reads a missing ledger as empty', () => { + expect(priorSessionIds(plan, envOf('S1'))).toEqual([]); + }); + + it('swallows an unwritable record dir', () => { + // The record dir path collides with an existing FILE: mkdir fails. The + // append must not throw — bookkeeping never takes the review down. + writeFileSync(join(root, 'qwen-review-pr-7-fetch-prompts'), 'a file'); + expect(() => appendRunSession(plan, envOf('S1'))).not.toThrow(); + }); +}); + +describe('resume marker', () => { + it('reads an absent marker as the empty history', () => { + expect(readResumeMarker(plan)).toEqual({ + schemaVersion: 1, + resumes: [], + restarts: [], + }); + }); + + it('round-trips resumes and restarts', () => { + recordResume(plan, envOf('S2')); + recordRestart(plan, 'head-moved abc1234->def5678'); + const marker = readResumeMarker(plan); + expect(marker.resumes.map((r) => r.sessionId)).toEqual(['S2']); + expect(marker.restarts.map((r) => r.reason)).toEqual([ + 'head-moved abc1234->def5678', + ]); + }); + + it('counts multiple resumes in order', () => { + recordResume(plan, envOf('S2')); + recordResume(plan, envOf('S3')); + expect(readResumeMarker(plan).resumes.map((r) => r.sessionId)).toEqual([ + 'S2', + 'S3', + ]); + }); + + it('refuses a resume under an invalid session id', () => { + recordResume(plan, envOf('a/b')); + expect(readResumeMarker(plan).resumes).toEqual([]); + }); + + it('reads a corrupt marker as the empty history', () => { + recordResume(plan, envOf('S2')); + writeFileSync(resumeMarkerPath(plan), ']['); + expect(readResumeMarker(plan)).toEqual({ + schemaVersion: 1, + resumes: [], + restarts: [], + }); + }); + + it('reads an unknown schemaVersion as the empty history', () => { + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + writeFileSync( + resumeMarkerPath(plan), + JSON.stringify({ schemaVersion: 2, resumes: [], restarts: [] }), + ); + expect(readResumeMarker(plan)).toEqual({ + schemaVersion: 1, + resumes: [], + restarts: [], + }); + }); + + it('drops marker entries older than the plan — a fresh run starts at zero', () => { + recordResume(plan, envOf('S2'), Date.now() - 60_000); + recordRestart(plan, 'head-moved', Date.now() - 60_000); + writeFileSync(plan, JSON.stringify({ diffLines: 2, chunks: [] })); + const marker = readResumeMarker(plan); + expect(marker.resumes).toEqual([]); + expect(marker.restarts).toEqual([]); + }); + + it('exports the resume cap', () => { + expect(RESUME_MAX).toBe(2); + }); +}); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts new file mode 100644 index 00000000000..f9fe1eea016 --- /dev/null +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Which CLI sessions this review ran under, written by the thing that ran them. +// +// A review interrupted mid-run and resumed (`--resume`) continues in a NEW CLI +// session: the harness keys its subagent transcripts on `QWEN_CODE_SESSION_ID`, +// so the first attempt's evidence sits in a directory the second attempt's +// environment no longer names. The readers that certify agent work (coverage, +// retirement, the recovery command) need the earlier directory's name — and the +// orchestrator must not be the one to supply it, for the same reason it is never +// given the prompt-record path: a path the model can choose is a path the model +// can point somewhere flattering. +// +// So `fetch-pr` appends its own session id here, read back later from disk. The +// entry is only ever an ADDRESS, never a verdict: a fabricated id can at most +// point a reader at a directory inside the harness's own `subagents/` tree, +// where credit still requires the content-shaped pairing (verbatim-delivered +// prompt, opened brief, diff reads) that fabrication cannot satisfy. +// +// The same file's sibling, `resume.json`, is the resume/restart bookkeeping the +// skill used to hold only in transcript memory: how many times this review has +// resumed, and whether it already restarted once for head movement. + +import { readFileSync, mkdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { atomicWriteFileSync } from '@qwen-code/qwen-code-core'; +import { promptRecordDir } from './prompt-record.js'; + +const SESSIONS_FILE = 'run-sessions.json'; +const RESUME_FILE = 'resume.json'; + +/** + * Hard cap on resumes of one review. The workflow's own retry loop allows a + * single retry (MAX_ATTEMPTS=2), so 2 leaves headroom for a manual rerun + * without permitting an unbounded resume chain on a review that keeps dying. + */ +export const RESUME_MAX = 2; + +/** + * Session ids are used to BUILD A PATH under the harness's `subagents/` dir, so + * the character set is closed: anything that could traverse (`/`, `\`, `..`) or + * smuggle separators fails the whole entry. Mirrors the shape the harness + * actually generates (UUIDs) with room for prefixed variants. + */ +const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +/** + * Same fence, same constant, same reason as `deadline.ts`'s `runEpochMs`: the + * ledger keys on the plan path, which is stable per PR, but every FRESH run + * rewrites the plan — so entries older than the plan's mtime belong to a + * previous review of the same PR and must be invisible. A resumed run + * deliberately does not rewrite the plan, which is exactly what keeps the + * first attempt's entries inside the fence. + */ +const RUN_EPOCH_SLACK_MS = 2000; + +function runEpochMs(planPath: string): number { + try { + return statSync(planPath).mtimeMs - RUN_EPOCH_SLACK_MS; + } catch { + return Number.NEGATIVE_INFINITY; + } +} + +interface SessionEntry { + sessionId: string; + atMs: number; +} + +/** Where the session ledger lives — derived from the plan path, never passed. */ +export function runSessionsPath(planPath: string): string { + return join(promptRecordDir(planPath), SESSIONS_FILE); +} + +/** + * This run's session entries, oldest first. Unreadable or malformed → empty: + * the failure direction is "earlier evidence invisible", which coverage answers + * by requiring the work again — never the reverse. + */ +function readSessions(planPath: string): SessionEntry[] { + try { + const parsed = JSON.parse( + readFileSync(runSessionsPath(planPath), 'utf8'), + ) as unknown; + if (!Array.isArray(parsed)) return []; + const epoch = runEpochMs(planPath); + return parsed.filter( + (e): e is SessionEntry => + typeof e === 'object' && + e !== null && + typeof (e as SessionEntry).sessionId === 'string' && + SESSION_ID_RE.test((e as SessionEntry).sessionId) && + typeof (e as SessionEntry).atMs === 'number' && + (e as SessionEntry).atMs >= epoch, + ); + } catch { + return []; + } +} + +/** + * Record the current session against this plan. Id comes from the environment + * the CLI itself exported, never from an argument. Write errors are swallowed + * for the same reason `stampRound` swallows them — a read-only tmp dir must + * not stop a review being built; it only costs a later resume its evidence. + */ +export function appendRunSession( + planPath: string, + env: NodeJS.ProcessEnv = process.env, + nowMs: number = Date.now(), +): void { + try { + const id = env['QWEN_CODE_SESSION_ID']?.trim(); + if (!id || !SESSION_ID_RE.test(id)) return; + const entries = readSessions(planPath); + if (entries.some((e) => e.sessionId === id)) return; + entries.push({ sessionId: id, atMs: nowMs }); + const dir = promptRecordDir(planPath); + mkdirSync(dir, { recursive: true }); + atomicWriteFileSync(runSessionsPath(planPath), JSON.stringify(entries), { + noFollow: true, + }); + } catch { + // Bookkeeping only; the review itself must not fail on it. + } +} + +/** + * Session ids of EARLIER attempts of this same run — the current session + * excluded, order preserved, deduplicated by the ledger's own append guard. + * These are addresses for `subagents/` lookups, nothing more. + */ +export function priorSessionIds( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const current = env['QWEN_CODE_SESSION_ID']?.trim(); + return readSessions(planPath) + .map((e) => e.sessionId) + .filter((id) => id !== current); +} + +/** Resume/restart bookkeeping for one review run. */ +export interface ResumeMarker { + schemaVersion: 1; + /** Each successful `--resume` continuation, in order. */ + resumes: Array<{ sessionId: string; atMs: number }>; + /** Each restart-for-head-movement, in order. The skill's cap is one. */ + restarts: Array<{ atMs: number; reason: string }>; +} + +// A fresh object every time: callers mutate the arrays (`recordResume` +// pushes into them), so a shared constant would accumulate history across +// reads. +const emptyMarker = (): ResumeMarker => ({ + schemaVersion: 1, + resumes: [], + restarts: [], +}); + +/** Where the resume marker lives — derived from the plan path, never passed. */ +export function resumeMarkerPath(planPath: string): string { + return join(promptRecordDir(planPath), RESUME_FILE); +} + +/** + * The marker, epoch-fenced like the session ledger: entries from a previous + * review of the same PR are dropped, so a fresh run always starts at zero + * resumes and zero restarts. Malformed → the empty marker (fail toward "no + * history", which the caps then treat most permissively — the hard bound on + * abuse is the session ledger's entry count and the workflow's MAX_ATTEMPTS). + */ +export function readResumeMarker(planPath: string): ResumeMarker { + try { + const parsed = JSON.parse( + readFileSync(resumeMarkerPath(planPath), 'utf8'), + ) as unknown; + if ( + typeof parsed !== 'object' || + parsed === null || + (parsed as ResumeMarker).schemaVersion !== 1 + ) { + return emptyMarker(); + } + const epoch = runEpochMs(planPath); + const raw = parsed as ResumeMarker; + const resumes = Array.isArray(raw.resumes) + ? raw.resumes.filter( + (e) => + typeof e === 'object' && + e !== null && + typeof e.sessionId === 'string' && + typeof e.atMs === 'number' && + e.atMs >= epoch, + ) + : []; + const restarts = Array.isArray(raw.restarts) + ? raw.restarts.filter( + (e) => + typeof e === 'object' && + e !== null && + typeof e.reason === 'string' && + typeof e.atMs === 'number' && + e.atMs >= epoch, + ) + : []; + return { schemaVersion: 1, resumes, restarts }; + } catch { + return emptyMarker(); + } +} + +function writeMarker(planPath: string, marker: ResumeMarker): void { + try { + const dir = promptRecordDir(planPath); + mkdirSync(dir, { recursive: true }); + atomicWriteFileSync(resumeMarkerPath(planPath), JSON.stringify(marker), { + noFollow: true, + }); + } catch { + // Bookkeeping only. + } +} + +/** Record a successful `--resume` continuation under the current session. */ +export function recordResume( + planPath: string, + env: NodeJS.ProcessEnv = process.env, + nowMs: number = Date.now(), +): void { + const id = env['QWEN_CODE_SESSION_ID']?.trim(); + if (!id || !SESSION_ID_RE.test(id)) return; + const marker = readResumeMarker(planPath); + marker.resumes.push({ sessionId: id, atMs: nowMs }); + writeMarker(planPath, marker); +} + +/** Record a restart-for-head-movement (the skill's once-per-review event). */ +export function recordRestart( + planPath: string, + reason: string, + nowMs: number = Date.now(), +): void { + const marker = readResumeMarker(planPath); + marker.restarts.push({ atMs: nowMs, reason }); + writeMarker(planPath, marker); +} diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index 876924081f7..767ad1220e9 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -11,11 +11,19 @@ // evidence" rather than throw and take the whole coverage check down. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { + mkdtempSync, + rmSync, + writeFileSync, + mkdirSync, + utimesSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { readTranscripts, + readRunTranscripts, + transcriptDirsForRun, wasGivenTheDiff, transcriptDir, TranscriptsUnavailableError, @@ -254,3 +262,141 @@ describe('wasGivenTheDiff', () => { expect(wasGivenTheDiff(rec(''), '/d.txt')).toBe(false); }); }); + +describe('readRunTranscripts — the run across its sessions', () => { + // A minimal valid transcript: launch prompt only. + const transcript = (agentId: string): string => + JSON.stringify({ + agentId, + agentName: 'general-purpose', + type: 'user', + message: { role: 'user', parts: [{ text: `launch ${agentId}` }] }, + }) + '\n'; + + // The run ledger fetch-pr would have written: sessions S0 (interrupted + // attempt) then S1 (current). Entries must postdate the plan's epoch. + function planWithLedger(...sessionIds: string[]): string { + const plan = join(dir, 'qwen-review-pr-7-fetch.json'); + writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); + const recordDir = join(dir, 'qwen-review-pr-7-fetch-prompts'); + mkdirSync(recordDir, { recursive: true }); + writeFileSync( + join(recordDir, 'run-sessions.json'), + JSON.stringify( + sessionIds.map((id) => ({ sessionId: id, atMs: Date.now() })), + ), + ); + return plan; + } + + function priorFile(session: string, name: string, contents: string): void { + mkdirSync(join(dir, 'subagents', session), { recursive: true }); + writeFileSync(join(dir, 'subagents', session, name), contents); + } + + it('unions prior-session transcripts, marked fromPriorSession', () => { + const plan = planWithLedger('S0', 'S1'); + priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + file('agent-a1.jsonl', transcript('a1')); + const recs = readRunTranscripts(plan, undefined, ENV); + expect(recs.map((r) => [r.agentId, r.fromPriorSession === true])).toEqual([ + ['a1', false], + ['a0', true], + ]); + }); + + it('reads only the current session when no ledger exists', () => { + // The orphan-invisibility guard: without a ledger entry, a prior + // session's transcripts do not exist to any reader. + const plan = join(dir, 'qwen-review-pr-7-fetch.json'); + writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); + priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + file('agent-a1.jsonl', transcript('a1')); + const recs = readRunTranscripts(plan, undefined, ENV); + expect(recs.map((r) => r.agentId)).toEqual(['a1']); + }); + + it('skips a prior session whose directory is gone, silently', () => { + const plan = planWithLedger('S0', 'S1'); + file('agent-a1.jsonl', transcript('a1')); + const recs = readRunTranscripts(plan, undefined, ENV); + expect(recs.map((r) => r.agentId)).toEqual(['a1']); + }); + + it('still throws when the CURRENT session dir is absent', () => { + const plan = planWithLedger('S0', 'S1'); + priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + expect(() => + readRunTranscripts(plan, undefined, { + QWEN_CODE_PROJECT_DIR: dir, + QWEN_CODE_SESSION_ID: 'S-gone', + }), + ).toThrow(TranscriptsUnavailableError); + }); + + it('applies the since fence to prior-session records too', () => { + const plan = planWithLedger('S0', 'S1'); + priorFile('S0', 'agent-a0.jsonl', transcript('a0')); + const past = new Date(Date.now() - 3600_000); + utimesSync(join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), past, past); + file('agent-a1.jsonl', transcript('a1')); + const recs = readRunTranscripts(plan, Date.now() - 60_000, ENV); + expect(recs.map((r) => r.agentId)).toEqual(['a1']); + }); + + it('lists the current session dir first, priors after, deduplicated', () => { + const plan = planWithLedger('S0', 'S0', 'S1'); + expect(transcriptDirsForRun(plan, ENV)).toEqual([ + join(dir, 'subagents', 'S1'), + join(dir, 'subagents', 'S0'), + ]); + }); +}); + +describe('readRunTranscripts — currentDirOptional', () => { + const transcript = (agentId: string): string => + JSON.stringify({ + agentId, + agentName: 'general-purpose', + type: 'user', + message: { role: 'user', parts: [{ text: `launch ${agentId}` }] }, + }) + '\n'; + + it('absorbs a missing CURRENT dir when asked — the pre-launch resume read', () => { + const plan = join(dir, 'qwen-review-pr-7-fetch.json'); + writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); + const recordDir = join(dir, 'qwen-review-pr-7-fetch-prompts'); + mkdirSync(recordDir, { recursive: true }); + writeFileSync( + join(recordDir, 'run-sessions.json'), + JSON.stringify([{ sessionId: 'S0', atMs: Date.now() }]), + ); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), + transcript('a0'), + ); + + const env = { QWEN_CODE_PROJECT_DIR: dir, QWEN_CODE_SESSION_ID: 'S-new' }; + // Without the option: the current dir is still load-bearing. + expect(() => readRunTranscripts(plan, undefined, env)).toThrow( + TranscriptsUnavailableError, + ); + const recs = readRunTranscripts(plan, undefined, env, undefined, { + currentDirOptional: true, + }); + expect(recs.map((r) => [r.agentId, r.fromPriorSession === true])).toEqual([ + ['a0', true], + ]); + }); + + it('still throws on a missing ENVIRONMENT even with the option', () => { + const plan = join(dir, 'qwen-review-pr-7-fetch.json'); + writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); + expect(() => + readRunTranscripts(plan, undefined, {}, undefined, { + currentDirOptional: true, + }), + ).toThrow(TranscriptsUnavailableError); + }); +}); diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index c0193968cad..def11130387 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -40,6 +40,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { ToolNames } from '@qwen-code/qwen-code-core'; import { join } from 'node:path'; +import { priorSessionIds } from './run-ledger.js'; /** One subagent, as the harness recorded it. */ export interface AgentRecord { @@ -91,6 +92,12 @@ export interface AgentRecord { finalText: string; /** When the transcript was last written. */ mtimeMs: number; + /** + * True when this record came from an EARLIER attempt's session directory — + * a resumed run reading the interrupted attempt's evidence. Absent on + * records from the current session, so existing readers are unchanged. + */ + fromPriorSession?: boolean; } /** Why no transcripts could be read. Never conflated with "the agents idled". */ @@ -390,6 +397,89 @@ export function readTranscripts( return out; } +/** + * Every transcript directory this RUN may have written to: the current + * session's, plus the directories of earlier attempts recorded in the run + * ledger (a resumed run continues in a new session; see `run-ledger.ts`). + * + * Directory names are assembled here from the env's project dir and the + * ledger's validated session ids — never taken from a caller. The current + * session's directory is always first. + */ +export function transcriptDirsForRun( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const { projectDir, dir } = transcriptPaths(env); + const dirs = [dir]; + for (const id of priorSessionIds(planPath, env)) { + const prior = join(projectDir, 'subagents', id); + if (!dirs.includes(prior)) dirs.push(prior); + } + return dirs; +} + +/** + * Every subagent THIS RUN launched, across all of the run's sessions. + * + * The single-session `readTranscripts` contract is preserved exactly for the + * current session: an unreadable current directory is an infrastructure fact + * and throws. A prior session's directory that cannot be read is different — + * its absence only means the earlier attempt's evidence is invisible, and the + * failure direction of invisible evidence is "require the work again", which + * every downstream gate already implements. So prior directories are skipped + * silently, never fabricated and never fatal. + * + * `since` stays the plan's mtime: a resumed run deliberately does not rewrite + * the plan, which is what keeps the first attempt's records inside the fence. + * + * `currentDirOptional` exists for exactly one caller shape: a resumed run + * reading the PREVIOUS attempt's evidence before this session has launched + * any agent — the harness creates `subagents/` on the first launch, + * so at that moment the current directory legitimately does not exist. A + * missing ENVIRONMENT (no session id, no project dir) still throws: that is + * an infrastructure fact whichever session it is. + */ +export function readRunTranscripts( + planPath: string, + since?: number, + env: NodeJS.ProcessEnv = process.env, + diffPath?: string, + opts: { currentDirOptional?: boolean } = {}, +): AgentRecord[] { + // Validates the env first, so the optional-dir branch below can only ever + // be absorbing "no directory yet", never "no environment". + transcriptPaths(env); + let out: AgentRecord[]; + try { + out = readTranscripts(since, env, diffPath); + } catch (err) { + if ( + !(err instanceof TranscriptsUnavailableError) || + opts.currentDirOptional !== true + ) { + throw err; + } + out = []; + } + for (const dir of transcriptDirsForRun(planPath, env).slice(1)) { + let names: string[]; + try { + names = listAgentTranscriptFiles(dir); + } catch { + continue; // Earlier attempt's evidence invisible → its work is re-owed. + } + for (const name of names) { + const rec = parseTranscript(join(dir, name), diffPath); + if (!rec) continue; + if (since !== undefined && rec.mtimeMs < since) continue; + rec.fromPriorSession = true; + out.push(rec); + } + } + return out; +} + /** * Was this agent given any way to reach the diff? * From bd3ffe7e47f3cf3ad1d8e011e5f5106b1ad04c2b Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 13 Aug 2026 23:38:14 +0800 Subject: [PATCH 02/21] fix(review): address review feedback on the session ledger Ledger.main drops its stale | null (computeLedger throws before folding when the current chat holds no above-floor record, so the renderLedger guard was dead code); recordResume/recordRestart gain dedup guards so a caller-side retry cannot double-count toward the resume cap or fake a second restart; fetch-pr's ledger append is now wired-tested (called with the plan path, after the plan write); and the layer-audit gate's real reader gets its own prior-session tests, including the discriminating partial-walk shape that separates invisible from credited. --- .../cli/src/commands/review/cost-ledger.ts | 19 ++- .../cli/src/commands/review/fetch-pr.test.ts | 60 +++++++ .../review/lib/layer-audit-gate.test.ts | 146 +++++++++++++++++- .../commands/review/lib/run-ledger.test.ts | 20 +++ .../cli/src/commands/review/lib/run-ledger.ts | 15 +- 5 files changed, 249 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/review/cost-ledger.ts b/packages/cli/src/commands/review/cost-ledger.ts index a8804fe8cef..dec3335dc4b 100644 --- a/packages/cli/src/commands/review/cost-ledger.ts +++ b/packages/cli/src/commands/review/cost-ledger.ts @@ -66,7 +66,12 @@ interface StreamCost { interface Ledger { totals: Omit & { wallSeconds: number }; - main: StreamCost | null; + /** + * Never null: `computeLedger` throws before folding when the current + * session's chat holds no above-floor record, so a ledger that exists + * always carries its main loop. + */ + main: StreamCost; agents: StreamCost[]; /** * How many EARLIER sessions of this run (a resumed review) contributed @@ -465,13 +470,11 @@ export function renderLedger(ledger: Ledger): string { `${human(t.outputTokens)} output (${human(t.thoughtsTokens)} thinking) · ` + `${Math.round(t.wallSeconds / 60)} min wall`, ); - if (ledger.main !== null) { - const m = ledger.main; - lines.push( - ` main loop: ${plural(m.calls, 'call')} · ${human(m.inputTokens)} in · ` + - `${human(m.outputTokens)} out`, - ); - } + const m = ledger.main; + lines.push( + ` main loop: ${plural(m.calls, 'call')} · ${human(m.inputTokens)} in · ` + + `${human(m.outputTokens)} out`, + ); if (ledger.priorSessions > 0) { lines.push( ` resumed run: totals include ${plural(ledger.priorSessions, 'earlier session')} of this review`, diff --git a/packages/cli/src/commands/review/fetch-pr.test.ts b/packages/cli/src/commands/review/fetch-pr.test.ts index 5175b85e773..050f373b53f 100644 --- a/packages/cli/src/commands/review/fetch-pr.test.ts +++ b/packages/cli/src/commands/review/fetch-pr.test.ts @@ -276,6 +276,13 @@ vi.mock('./lib/merge-base.js', () => ({ resolveMergeBase: vi.fn(() => ({ sha: null, baseFetchFailed: false })), })); +// The ledger append is the wiring under test here, not the ledger itself +// (run-ledger.test.ts owns that): a silently unwritten ledger would make a +// later --resume find no prior sessions and re-run everything. +vi.mock('./lib/run-ledger.js', () => ({ + appendRunSession: vi.fn(), +})); + describe('fetch-pr report assembly', () => { beforeEach(() => { vi.clearAllMocks(); @@ -696,3 +703,56 @@ describe('fetch-pr diff identity (diffSha256)', () => { expect(report.diffSha256).toBeNull(); }); }); + +describe('fetch-pr run-session ledger wiring', () => { + beforeEach(() => { + vi.clearAllMocks(); + producerMocks.readFileSync.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + producerMocks.git.mockImplementation((...args: string[]) => + args[0] === 'rev-parse' ? 'f00df00df00d' : '', + ); + producerMocks.gh.mockReturnValue( + JSON.stringify({ + headRefName: 'feat/x', + headRefOid: 'f00df00df00d', + baseRefName: 'main', + additions: 1, + deletions: 0, + changedFiles: 1, + isCrossRepository: false, + body: '', + }), + ); + }); + + it('appends the session against the plan it just wrote, after the write', async () => { + const handler = fetchPrCommand.handler; + if (!handler) throw new Error('fetch-pr handler missing'); + await handler({ + _: [], + $0: 'qwen', + pr_number: '42', + owner_repo: 'acme/widgets', + remote: 'origin', + out: '/tmp/fetch-report.json', + maxChunkLines: 400, + } as unknown as Parameters[0]); + + const { appendRunSession } = await import('./lib/run-ledger.js'); + expect(vi.mocked(appendRunSession)).toHaveBeenCalledWith( + '/tmp/fetch-report.json', + ); + // After the plan write: the entry must sit inside the run-epoch fence the + // readers apply, which is keyed on the plan's mtime. + const appendOrder = vi.mocked(appendRunSession).mock.invocationCallOrder[0]; + const writeOrder = producerMocks.writeFileSync.mock.invocationCallOrder.at( + producerMocks.writeFileSync.mock.calls.findIndex( + ([path]) => path === '/tmp/fetch-report.json', + ), + ); + expect(writeOrder).toBeDefined(); + expect(appendOrder).toBeGreaterThan(writeOrder as number); + }); +}); diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index 25b9b3fd724..a53e06f7997 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -161,3 +167,141 @@ describe('layerAuditGate', () => { ).toEqual([]); }); }); + +describe('the real reader on a resumed run — prior-session auditors count', () => { + // Every other suite injects readReturns; these exercise the DEFAULT reader + // against real transcript files, because this caller filters on the + // reverse-audit identity line and corroborates with territory reads — a + // path the shared readRunTranscripts tests cannot cover. + let dir: string; + let plan: string; + let diff: string; + + const LAYERS = [ + 'lexing', + 'expansion', + 'scope-propagation', + 'resolution-order', + 'inheritance', + 'toctou', + ]; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'layer-gate-resume-')); + plan = join(dir, 'plan.json'); + diff = join(dir, 'diff.txt'); + writeFileSync( + plan, + JSON.stringify({ + repositoryContext: context([MODELED_SYSTEM_DOMAIN]), + diffPathAbsolute: diff, + }), + ); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + mkdirSync(join(dir, 'subagents', 'S1'), { recursive: true }); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + mkdirSync(join(dir, 'plan-prompts'), { recursive: true }); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + const ENV = (): NodeJS.ProcessEnv => ({ + QWEN_CODE_PROJECT_DIR: dir, + QWEN_CODE_SESSION_ID: 'S1', + }); + + function ledger(...ids: string[]): void { + writeFileSync( + join(dir, 'plan-prompts', 'run-sessions.json'), + JSON.stringify(ids.map((id) => ({ sessionId: id, atMs: Date.now() }))), + ); + } + + /** A corroborated reverse auditor in `session`: identity line, a baked + * territory read it actually performed, and receipts for `covered`. */ + function auditorTranscript(session: string, covered: string[]): void { + const launch = + 'You are review agent `reverse-audit` — hunt the gaps.\n' + + `read_file(file_path="${diff}", offset=0, limit=100)`; + const base = { agentId: `ra-${session}`, agentName: 'general-purpose' }; + const lines = [ + JSON.stringify({ + ...base, + type: 'user', + message: { role: 'user', parts: [{ text: launch }] }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: diff, offset: 0, limit: 100 }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'diff bytes' }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + text: covered + .map((id) => `Layer walked: ${id} — examined.`) + .join('\n'), + }, + ], + }, + }), + ]; + writeFileSync( + join(dir, 'subagents', session, `agent-ra-${session}.jsonl`), + lines.join('\n') + '\n', + ); + } + + it("credits the interrupted attempt's walked layers through the ledger", () => { + ledger('S0', 'S1'); + auditorTranscript('S0', LAYERS); + expect(layerAuditGate(plan, ENV()).unreviewed).toEqual([]); + }); + + it('still owes the layers the prior auditor did not walk', () => { + ledger('S0', 'S1'); + auditorTranscript('S0', ['lexing', 'expansion']); + const out = layerAuditGate(plan, ENV()).unreviewed; + expect(out).toHaveLength(4); + expect(out.some((e) => e.includes('scope-propagation'))).toBe(true); + expect(out.some((e) => e.includes('lexing'))).toBe(false); + }); + + it('sees nothing from a prior session the ledger never recorded', () => { + // A PARTIAL walk is the discriminating shape: were the un-ledgered + // transcript visible, one identity-matched auditor covering two layers + // would owe the other four; invisible, identityMatched is 0 and the + // reverse-audit-ran floor owns it — the gate defers entirely. + auditorTranscript('S0', ['lexing', 'expansion']); + expect(layerAuditGate(plan, ENV()).unreviewed).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 0347e455be0..0c68df450ba 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -196,3 +196,23 @@ describe('resume marker', () => { expect(RESUME_MAX).toBe(2); }); }); + +describe('marker dedup — a caller retry must not double-count', () => { + it('records one resume per session', () => { + recordResume(plan, envOf('S2')); + recordResume(plan, envOf('S2')); + expect(readResumeMarker(plan).resumes).toHaveLength(1); + }); + + it('records one restart per reason', () => { + recordRestart(plan, 'head-moved abc1234->def5678'); + recordRestart(plan, 'head-moved abc1234->def5678'); + expect(readResumeMarker(plan).restarts).toHaveLength(1); + }); + + it('still records distinct restarts', () => { + recordRestart(plan, 'head-moved abc1234->def5678'); + recordRestart(plan, 'head-moved def5678->0123abc'); + expect(readResumeMarker(plan).restarts).toHaveLength(2); + }); +}); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index f9fe1eea016..908eeea0386 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -226,7 +226,12 @@ function writeMarker(planPath: string, marker: ResumeMarker): void { } } -/** Record a successful `--resume` continuation under the current session. */ +/** + * Record a successful `--resume` continuation under the current session. + * One entry per session, like the session ledger's own guard: a session + * resumes a run at most once, so a repeated call is a caller-side retry and + * must not spend the resume cap twice. + */ export function recordResume( planPath: string, env: NodeJS.ProcessEnv = process.env, @@ -235,17 +240,23 @@ export function recordResume( const id = env['QWEN_CODE_SESSION_ID']?.trim(); if (!id || !SESSION_ID_RE.test(id)) return; const marker = readResumeMarker(planPath); + if (marker.resumes.some((r) => r.sessionId === id)) return; marker.resumes.push({ sessionId: id, atMs: nowMs }); writeMarker(planPath, marker); } -/** Record a restart-for-head-movement (the skill's once-per-review event). */ +/** + * Record a restart-for-head-movement (the skill's once-per-review event). + * Deduplicated by reason: the event is at-most-once by rule, so a repeated + * identical call is a caller-side retry, not a second restart. + */ export function recordRestart( planPath: string, reason: string, nowMs: number = Date.now(), ): void { const marker = readResumeMarker(planPath); + if (marker.restarts.some((r) => r.reason === reason)) return; marker.restarts.push({ atMs: nowMs, reason }); writeMarker(planPath, marker); } From 4648428cfd471749b23ad1606e56624be703fb71 Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 02:47:57 +0800 Subject: [PATCH 03/21] fix(review): keep the run epoch stable and the continuity note non-capping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Criticals from the automatic review. repo-context's enrichment rewrite advanced the plan's mtime — the run epoch every fence keys on — which orphaned the session-ledger entry fetch-pr had appended an orchestrator turn earlier: on a resume the prior attempt's transcripts were invisible and the feature silently re-ran everything in the primary medium/high flow. The rewrite now restores the plan's mtime (enrichment is not a re-capture), pinned by a test that backdates the plan and asserts the mtime survives the command. The continuity disclosure also moved off the capping disclose() channel: compose-review folds every disclosure into the unreviewed-dimension cap and the "Not reviewed:" rendering, so any resumed run that recovered work was permanently downgraded to COMMENT and its reused work called not-reviewed — unrepairably, since the prior records never leave the ledger. Coverage now only counts recoveredAgents, and compose-review renders its own continuity block beside the other disclosed-but-not-capping notes (deferred lint, test-plan rulings), on every verdict including Approve. A new compose test pins the clean resumed run at APPROVE with the note and without the partial-review opener. --- .../commands/review/check-coverage.test.ts | 16 +++++----- .../commands/review/compose-review.test.ts | 30 +++++++++++++++++++ .../cli/src/commands/review/compose-review.ts | 28 ++++++++++++++++- .../cli/src/commands/review/lib/coverage.ts | 18 +++++------ .../src/commands/review/repo-context.test.ts | 30 +++++++++++++++++++ .../cli/src/commands/review/repo-context.ts | 10 +++++++ 6 files changed, 112 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index eb471b75a56..0e6a5576b20 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2193,11 +2193,12 @@ describe('coverage — a resumed run credits the prior attempt through the ledge expect(r.ok).toBe(true); expect(r.coveredChunks).toEqual([1, 2]); expect(r.recoveredAgents).toBeGreaterThanOrEqual(1); - expect( - r.disclosures.some( - (d) => d.subject === 'review continuity' && /resumed/.test(d.reason), - ), - ).toBe(true); + // Continuity is NOT a disclosure: that channel caps the verdict and + // renders under "Not reviewed:" — recovered work is the opposite of a + // gap. compose-review renders its own non-capping note from the count. + expect(r.disclosures.some((d) => d.subject === 'review continuity')).toBe( + false, + ); }); it('sees nothing from a prior session the ledger never recorded', () => { @@ -2231,14 +2232,11 @@ describe('coverage — a resumed run credits the prior attempt through the ledge expect(r.recoveredAgents).toBe(0); }); - it('emits no continuity disclosure on a run that never resumed', () => { + it('reports zero recovered agents on a run that never resumed', () => { transcript('a1', good(1), { calls: 3 }); transcript('a2', good(2), { calls: 2 }); const r = coverageFromTranscripts(plan(), ENV); expect(r.recoveredAgents).toBe(0); - expect(r.disclosures.some((d) => d.subject === 'review continuity')).toBe( - false, - ); }); }); diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index 2a40cd39e13..d75ffb9c4f7 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, readFileSync, + renameSync, writeFileSync, mkdirSync, rmSync, @@ -5791,3 +5792,32 @@ describe('composeReview — unresolved-Critical rendering (#8388 readability)', expect(r.body).toContain('comment 102 (b.ts) — body truncated'); }); }); + +describe('composeReview — a resumed run is continuity, not a coverage gap', () => { + it('stays APPROVE and renders the non-capping continuity note', () => { + // The interrupted attempt's chunk-1 agent, re-homed into session S0 and + // named by the run ledger; the current session covers the rest. The + // recovered work COUNTS as reviewed: no cap, no "Not reviewed:" entry — + // a capping entry here downgraded every clean resumed run to COMMENT, + // permanently, since the prior records never leave the ledger. + const p = coveredPlan(); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + renameSync( + join(dir, 'subagents', 'S1', 'agent-a1.jsonl'), + join(dir, 'subagents', 'S0', 'agent-a1.jsonl'), + ); + writeFileSync( + join(promptRecordDir(p), 'run-sessions.json'), + JSON.stringify([ + { sessionId: 'S0', atMs: Date.now() }, + { sessionId: 'S1', atMs: Date.now() }, + ]), + ); + + const r = composeReview(base({ planPath: p })); + expect(r.event).toBe('APPROVE'); + expect(r.body).toContain('Resumed run (not a gap): 1 agent result(s)'); + expect(r.body).not.toContain('Not reviewed: review continuity'); + expect(r.body).not.toContain('Partially reviewed'); + }); +}); diff --git a/packages/cli/src/commands/review/compose-review.ts b/packages/cli/src/commands/review/compose-review.ts index 9acca061688..ae98af8bfe7 100644 --- a/packages/cli/src/commands/review/compose-review.ts +++ b/packages/cli/src/commands/review/compose-review.ts @@ -1051,6 +1051,12 @@ function composeReviewBody( // on every gap here would make the soft ceiling hard: any large diff's // routine budget stop would forbid an Approve the review otherwise earned. const budgetGapNotes: Array<{ agent: string; gaps: string[] }> = []; + // Certified agent results recovered from an interrupted earlier attempt + // (a resumed run). Informational, NEVER capping: recovered work is counted + // AS reviewed, so it must not ride `coverageEntries` — an entry there caps + // the verdict and renders under "Not reviewed:", the exact opposite of the + // fact. Rendered as its own disclosed-but-not-capping block below. + let recoveredFromPriorAttempt = 0; // Sibling caps MAX_DIMENSIONS and MAX_NOTES bound their lists for the // same reason; this bounds the one budget-gap sentence. const MAX_BUDGET_GAP_LINES = 5; @@ -1255,6 +1261,7 @@ function composeReviewBody( ); } budgetGapNotes.push(...cov.budgetGaps); + recoveredFromPriorAttempt = cov.recoveredAgents; // The prompt was built in code and edited on the way to the agent. This caps // for the same reason the others do: what the agent was actually asked is not // what this skill's guarantees are written against. @@ -2079,6 +2086,19 @@ function composeReviewBody( ] : []; + // The resumed-run continuity note: the run reused certified work from an + // interrupted earlier attempt. Disclosed on every verdict — Approve + // included — and never capping: the recovered agents were re-certified + // from the harness records and COUNT as reviewed. + const continuityBlock: Bi[] = recoveredFromPriorAttempt + ? [ + { + en: `Resumed run (not a gap): ${recoveredFromPriorAttempt} agent result(s) from the interrupted earlier attempt were re-certified from the harness records and counted as reviewed.`, + zh: `续跑运行(非缺口):复用了被中断的前一次尝试的 ${recoveredFromPriorAttempt} 个 agent 结果,均已按 harness 记录重新认证并计入审查。`, + }, + ] + : []; + if (event === 'REQUEST_CHANGES') { // Empty body, except the disclosures: every clause whose state holds // appears on every event — a confirmed blocker must not squeeze out the @@ -2096,6 +2116,7 @@ function composeReviewBody( ...repositoryContextBlock, ...unlicensedDeferralBlock, ...deferredSuggestionsBlock, + ...continuityBlock, ...bodyCriticalBlock, ]; return { @@ -2134,12 +2155,14 @@ function composeReviewBody( ...repositoryContextBlock, ...unlicensedDeferralBlock, ...deferredSuggestionsBlock, + ...continuityBlock, ], notReviewedParts.length || deferredBlock.length || testPlanBlock.length || repositoryContextBlock.length || - deferredSuggestionsBlock.length + deferredSuggestionsBlock.length || + continuityBlock.length ? '\n\n' : ' ', ), @@ -2295,6 +2318,9 @@ function composeReviewBody( // precedes the list (non-capping). clauses.push(...unlicensedDeferralBlock); clauses.push(...deferredSuggestionsBlock); + // 6e. Resumed-run continuity (non-capping) — reused work that COUNTS as + // reviewed, disclosed so the author knows two attempts fed this verdict. + clauses.push(...continuityBlock); // 7. Body Criticals — on a COMMENT that stands where a REQUEST_CHANGES // would have been: the presubmit carve-out, and the unverified-blockers diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 93550801219..1ee2551939f 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -1000,19 +1000,17 @@ export function coverageFromTranscripts( } return false; }; + // NOT pushed through `disclose()`: that channel caps (compose-review folds + // every disclosure into the unreviewed-dimension cap and the "Not + // reviewed:" rendering), and recovered work is the OPPOSITE of a gap — a + // capping entry here would downgrade every clean resumed run to COMMENT, + // permanently, since the prior records never leave the ledger. + // compose-review reads the count off this report and renders its own + // non-capping continuity note, beside the other disclosed-but-not-capping + // blocks (deferred lint, test-plan notes). const recoveredAgents = records.filter( (r) => r.fromPriorSession && certifies(r), ).length; - if (recoveredAgents > 0) { - disclose( - 'review continuity', - `resumed — ${recoveredAgents} agent result(s) recovered from an interrupted earlier attempt`, - { - subjectZh: '评审续跑', - reasonZh: `本次为续跑 — 复用了上一次中断运行的 ${recoveredAgents} 个 agent 结果`, - }, - ); - } return { ok: diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index ab9493812a9..4d380fbee68 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -12,7 +12,9 @@ import { readFileSync, realpathSync, rmSync, + statSync, symlinkSync, + utimesSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -1018,3 +1020,31 @@ describe('repo-context providers and trust boundary', () => { }, ); }); + +describe('the plan mtime is the run epoch — enrichment must not advance it', () => { + // The run-session ledger, deadline stamps, prompt records and transcripts + // are all fenced on the plan's mtime, and fetch-pr's session entry lands an + // orchestrator turn BEFORE this command runs. A rewrite that advanced the + // mtime re-keyed the epoch mid-run and orphaned everything recorded before + // it — a resumed run then saw no prior evidence at all. + it('preserves the plan mtime across the enrichment rewrite', () => { + const root = mkdtempSync(join(tmpdir(), 'repo-context-epoch-')); + try { + const worktree = join(root, 'wt'); + mkdirSync(worktree, { recursive: true }); + const planPath = planAt(root, { files: [{ path: 'src/a.ts' }] }); + const before = new Date(Date.now() - 3600_000); + utimesSync(planPath, before, before); + const mtimeBefore = statSync(planPath).mtimeMs; + + runRepoContext({ plan: planPath, worktree, out: join(root, 'ctx.json') }); + + expect(statSync(planPath).mtimeMs).toBe(mtimeBefore); + // The rewrite itself still happened: the plan carries no context here, + // but the write path ran (content is re-serialized). + expect(() => JSON.parse(readFileSync(planPath, 'utf8'))).not.toThrow(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/review/repo-context.ts b/packages/cli/src/commands/review/repo-context.ts index 8b5b5a9b0a8..6d8d01fc699 100644 --- a/packages/cli/src/commands/review/repo-context.ts +++ b/packages/cli/src/commands/review/repo-context.ts @@ -12,6 +12,7 @@ import { readFileSync, realpathSync, statSync, + utimesSync, } from 'node:fs'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { writeStdoutLine } from '../../utils/stdioHelpers.js'; @@ -400,7 +401,16 @@ export function runRepoContext( mkdirSync(dirname(outPath), { recursive: true }); atomicWriteFileSync(outPath, `${JSON.stringify(context, null, 2)}\n`); + // The plan's mtime is the RUN EPOCH: deadline stamps, prompt records, + // transcripts and the run-session ledger are all fenced on it, and entries + // written before this enrichment (fetch-pr's session entry, ~an + // orchestrator turn earlier) must stay inside the fence. This write + // enriches the same run's plan — it is not a re-capture — so the mtime is + // restored after it; letting it advance re-keyed the epoch mid-run and + // silently orphaned everything recorded before this command ran. + const planStat = statSync(planPath); atomicWriteFileSync(planPath, stringifyPlanReport(plan)); + utimesSync(planPath, planStat.atime, planStat.mtime); writeStdoutLine( context === null ? `Wrote null repository context to ${outPath}` From 6fb01c06494bc08e1c3d71be0432d522a2c0b032 Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 07:55:47 +0800 Subject: [PATCH 04/21] fix(review): contain, bound and supersede the prior-session reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 blockers on the ledger PR. The currentDirOptional escape is now narrow on both axes — only ENOENT (the error's cause travels with it) and only when the run ledger actually names prior sessions — and it is passed at every reader that runs before the resumed session launches anything: coverageFromTranscripts, verificationGaps, the retirement scheduler and the layer-audit gate, which without it failed OPEN on the layers a prior attempt never walked. Prior-session directories now come from one guarded accessor that skips a symlinked subagents/, so a planted link cannot feed foreign transcripts to the transcript union or to the cost ledger, which reads file content with no certification step. The ledger also clamps each prior chat to the moment the next attempt began (an interrupted CLI session that kept serving unrelated turns was billed as review cost), discloses an unreadable prior agent dir instead of silently flooring it, and sums each session's own span for wall time rather than the envelope across the dead gap. Coverage's Uncoverable declaration takes the supersession guard its sibling flags already had: a stale declaration deleted live coverage post-loop and order-independently, so no relaunch could ever clear the cap; a record that declared a chunk unreachable is also no longer counted as recovered. The ledger read path deduplicates and applies the session-id charset gate to the resume marker too. --- .../commands/review/check-coverage.test.ts | 76 ++++++++- .../commands/review/compose-review.test.ts | 36 ++++ .../src/commands/review/cost-ledger.test.ts | 156 ++++++++++++++++++ .../cli/src/commands/review/cost-ledger.ts | 100 +++++++++-- .../cli/src/commands/review/lib/coverage.ts | 18 +- .../commands/review/lib/layer-audit-gate.ts | 10 +- .../cli/src/commands/review/lib/retirement.ts | 8 +- .../commands/review/lib/run-ledger.test.ts | 75 +++++++++ .../cli/src/commands/review/lib/run-ledger.ts | 43 ++++- .../commands/review/lib/transcripts.test.ts | 86 ++++++++++ .../src/commands/review/lib/transcripts.ts | 91 ++++++++-- 11 files changed, 660 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 0e6a5576b20..397de941ccf 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2192,7 +2192,7 @@ describe('coverage — a resumed run credits the prior attempt through the ledge const r = coverageFromTranscripts(p, ENV); expect(r.ok).toBe(true); expect(r.coveredChunks).toEqual([1, 2]); - expect(r.recoveredAgents).toBeGreaterThanOrEqual(1); + expect(r.recoveredAgents).toBe(1); // Continuity is NOT a disclosure: that channel caps the verdict and // renders under "Not reviewed:" — recovered work is the opposite of a // gap. compose-review renders its own non-capping note from the count. @@ -2240,3 +2240,77 @@ describe('coverage — a resumed run credits the prior attempt through the ledge expect(r.recoveredAgents).toBe(0); }); }); + +describe('coverage — a stale Uncoverable declaration cannot cap live coverage', () => { + function ledger(planPath: string, ...ids: string[]): void { + const d = promptRecordDir(planPath); + mkdirSync(d, { recursive: true }); + writeFileSync( + join(d, 'run-sessions.json'), + JSON.stringify(ids.map((id) => ({ sessionId: id, atMs: Date.now() }))), + ); + } + + function moveToSession(id: string, session: string): void { + mkdirSync(join(dir, 'subagents', session), { recursive: true }); + renameSync( + join(dir, 'subagents', 'S1', `agent-${id}.jsonl`), + join(dir, 'subagents', session, `agent-${id}.jsonl`), + ); + } + + it('a superseded prior-attempt declaration does not delete the chunk it covers', () => { + // The prior attempt's chunk-1 agent declared chunk 1 unreachable; this + // run's chunk-1 agent read it. The post-loop `covered.delete()` is + // order-independent, so without the supersession guard no relaunch could + // ever clear the cap — on lines this run demonstrably read. + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1old', good(1), { + calls: 1, + text: 'Uncoverable: chunk 1 — line exceeds the read limit', + }); + moveToSession('a1old', 'S0'); + transcript('a1', good(1), { calls: 3 }); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.uncoverableChunks).toEqual([]); + expect(r.coveredChunks).toEqual([1, 2]); + expect(r.ok).toBe(true); + // ...and the declaring record is not announced as recovered work. + expect(r.recoveredAgents).toBe(0); + }); + + it('an unsuperseded declaration still caps, resumed or not', () => { + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1old', good(1), { + calls: 1, + text: 'Uncoverable: chunk 1 — line exceeds the read limit', + }); + moveToSession('a1old', 'S0'); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.uncoverableChunks).toEqual([1]); + expect(r.ok).toBe(false); + }); + + it('credits the prior attempt when this session launched nothing at all', () => { + // The zero-launch continuation: the harness creates subagents/ + // on the first launch, so a run that recovered everything has no dir. + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1', good(1), { calls: 3 }); + transcript('a2', good(2), { calls: 2 }); + for (const name of readdirSync(join(dir, 'subagents', 'S1'))) { + moveToSession(name.replace(/^agent-|\.jsonl$/g, ''), 'S0'); + } + rmSync(join(dir, 'subagents', 'S1'), { recursive: true, force: true }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.coveredChunks).toEqual([1, 2]); + expect(r.recoveredAgents).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index d75ffb9c4f7..5a46d1fd01b 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -5821,3 +5821,39 @@ describe('composeReview — a resumed run is continuity, not a coverage gap', () expect(r.body).not.toContain('Partially reviewed'); }); }); + +describe('composeReview — continuity renders on every verdict', () => { + /** A resumed run: chunk-1's agent re-homed to the ledgered prior session. */ + function resumedPlan(): string { + const p = coveredPlan(); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + renameSync( + join(dir, 'subagents', 'S1', 'agent-a1.jsonl'), + join(dir, 'subagents', 'S0', 'agent-a1.jsonl'), + ); + writeFileSync( + join(promptRecordDir(p), 'run-sessions.json'), + JSON.stringify([ + { sessionId: 'S0', atMs: Date.now() }, + { sessionId: 'S1', atMs: Date.now() }, + ]), + ); + return p; + } + + it('renders on REQUEST_CHANGES', () => { + const r = composeReview( + base({ planPath: resumedPlan(), criticalsInline: 1 }), + ); + expect(r.event).toBe('REQUEST_CHANGES'); + expect(r.body).toContain('Resumed run (not a gap): 1 agent result(s)'); + }); + + it('renders on COMMENT', () => { + const r = composeReview( + base({ planPath: resumedPlan(), suggestionsInline: 1 }), + ); + expect(r.event).toBe('COMMENT'); + expect(r.body).toContain('Resumed run (not a gap): 1 agent result(s)'); + }); +}); diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index e2174715452..1ee99d4463c 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -6,11 +6,13 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { + chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, + symlinkSync, utimesSync, writeFileSync, } from 'node:fs'; @@ -1447,3 +1449,157 @@ describe('cost-ledger — a resumed run bills the whole review', () => { expect(ledger.totals.inputTokens).toBe(2_500); }); }); + +describe('cost-ledger — prior-session bounds, faults and wall time', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + function fixture(): { + plan: string; + env: NodeJS.ProcessEnv; + project: string; + } { + const project = mkdtempSync(join(tmpdir(), 'ledger-bounds-')); + dirs.push(project); + mkdirSync(join(project, 'chats'), { recursive: true }); + mkdirSync(join(project, 'subagents', SESSION), { recursive: true }); + writeFileSync( + join(project, 'chats', `${SESSION}.jsonl`), + event('2026-08-03T10:10:00Z', { input: 500, output: 50 }), + ); + const plan = join(project, 'plan.json'); + writeFileSync( + plan, + JSON.stringify({ + diffPathAbsolute: join(project, 'diff.txt'), + diffLines: 10, + chunks: [{ id: 1, startLine: 1, endLine: 10 }], + }), + ); + const start = new Date('2026-08-03T10:00:00Z'); + utimesSync(plan, start, start); + return { + plan, + project, + env: { + QWEN_CODE_PROJECT_DIR: project, + QWEN_CODE_SESSION_ID: SESSION, + } as NodeJS.ProcessEnv, + }; + } + + /** The ledger fetch-pr writes: S0 interrupted, the current session resumed. */ + function runLedger( + project: string, + resumedAt = '2026-08-03T10:09:00Z', + ): void { + const d = join(project, 'plan-prompts'); + mkdirSync(d, { recursive: true }); + writeFileSync( + join(d, 'run-sessions.json'), + JSON.stringify([ + { sessionId: 'S0', atMs: Date.parse('2026-08-03T10:00:30Z') }, + { sessionId: SESSION, atMs: Date.parse(resumedAt) }, + ]), + ); + } + + it("clamps a prior session's chat to the moment the next attempt began", () => { + // The interrupted CLI session went on serving unrelated turns after the + // review died; billing those as review cost is the mirror of the + // omission that folding prior cost exists to fix. + const { plan, env, project } = fixture(); + runLedger(project); + mkdirSync(join(project, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + [ + event('2026-08-03T10:01:00Z', { input: 1_000, output: 100 }), + // After the resume began: another conversation, not this review. + event('2026-08-03T18:00:00Z', { input: 9_000, output: 900 }), + ].join('\n'), + ); + + const ledger = computeLedger(plan, env); + expect(ledger.priorSessions).toBe(1); + expect(ledger.totals.inputTokens).toBe(1_500); + }); + + it('discloses an unreadable prior agent dir instead of silently flooring it', () => { + const { plan, env, project } = fixture(); + runLedger(project); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:01:00Z', { input: 1_000, output: 100 }), + ); + const priorDir = join(project, 'subagents', 'S0'); + mkdirSync(priorDir, { recursive: true }); + chmodSync(priorDir, 0o000); + try { + const seen: string[] = []; + const spy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: unknown) => { + seen.push(String(chunk)); + return true; + }); + let ledger; + try { + ledger = computeLedger(plan, env); + } finally { + spy.mockRestore(); + } + expect(ledger.priorSessions).toBe(1); + expect( + seen.some((l) => l.includes("prior session's subagent transcripts")), + ).toBe(true); + } finally { + chmodSync(priorDir, 0o755); + } + }); + + it('never reads a symlinked prior session directory', () => { + const { plan, env, project } = fixture(); + runLedger(project); + const outside = mkdtempSync(join(tmpdir(), 'ledger-foreign-')); + dirs.push(outside); + writeFileSync( + join(outside, 'agent-foreign.jsonl'), + [ + userRecord('You are review agent `2` — Agent 2: Security.'), + event('2026-08-03T10:02:00Z', { input: 7_000, output: 700 }), + ].join('\n'), + ); + symlinkSync(outside, join(project, 'subagents', 'S0')); + + const ledger = computeLedger(plan, env); + expect(ledger.agents).toHaveLength(0); + expect(ledger.totals.inputTokens).toBe(500); + }); + + it("sums each session's own span rather than spanning the dead gap", () => { + const { plan, env, project } = fixture(); + runLedger(project); + mkdirSync(join(project, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + [ + event('2026-08-03T10:01:00Z', { input: 100, output: 10 }), + event('2026-08-03T10:02:00Z', { input: 100, output: 10 }), + ].join('\n'), + ); + writeFileSync( + join(project, 'chats', `${SESSION}.jsonl`), + [ + event('2026-08-03T10:10:00Z', { input: 100, output: 10 }), + event('2026-08-03T10:13:00Z', { input: 100, output: 10 }), + ].join('\n'), + ); + + const ledger = computeLedger(plan, env); + // 60s (prior) + 180s (current) — not the 720s envelope. + expect(ledger.totals.wallSeconds).toBe(240); + }); +}); diff --git a/packages/cli/src/commands/review/cost-ledger.ts b/packages/cli/src/commands/review/cost-ledger.ts index dec3335dc4b..22fb7a06ff4 100644 --- a/packages/cli/src/commands/review/cost-ledger.ts +++ b/packages/cli/src/commands/review/cost-ledger.ts @@ -39,11 +39,12 @@ import { import { transcriptPaths, listAgentTranscriptFiles, + priorSessionDirs, TranscriptsUnavailableError, textOf, } from './lib/transcripts.js'; import { labelFromIdentityLine } from './lib/agent-identity.js'; -import { priorSessionIds } from './lib/run-ledger.js'; +import { priorSessionEntries } from './lib/run-ledger.js'; interface CostLedgerArgs { plan: string; @@ -108,6 +109,7 @@ interface UsageEvent { function readUsage( file: string, floorMs: number, + ceilingMs?: number, ): { events: UsageEvent[]; launch: string } { const raw = readFileSync(file, 'utf8'); const events: UsageEvent[] = []; @@ -135,6 +137,11 @@ function readUsage( // conversation to the review. The plan's own mtime marks the review start // — the same floor `check-coverage` applies to transcripts. if (!Number.isFinite(tsMs) || tsMs < floorMs) continue; + // A prior session's window closes when the NEXT attempt began: the old + // CLI session may have gone on serving unrelated turns after this + // review was interrupted, and billing those to the review is the exact + // mirror of the omission folding prior cost exists to fix. + if (ceilingMs !== undefined && tsMs >= ceilingMs) continue; // Finite ≥ 0, else null: the main loop coerces broken-proxy usage // (negative or NaN counts) before recording, but the agent path records // raw provider usage, and each consumer below picks its own fallback @@ -309,6 +316,19 @@ function planFloorMs(planPath: string): number { return floorMs; } +/** The first and last moment a set of usage events covers. */ +function spanOf(events: UsageEvent[]): { firstMs: number; lastMs: number } { + let firstMs = Number.POSITIVE_INFINITY; + let lastMs = Number.NEGATIVE_INFINITY; + for (const e of events) { + if (e.timestampMs < firstMs) firstMs = e.timestampMs; + if (e.timestampMs > lastMs) lastMs = e.timestampMs; + } + return Number.isFinite(firstMs) + ? { firstMs, lastMs } + : { firstMs: 0, lastMs: 0 }; +} + export function computeLedger( planPath: string, env: NodeJS.ProcessEnv = process.env, @@ -387,24 +407,62 @@ export function computeLedger( // so unreadable prior state is skipped, never fatal, and the count of // sessions that did contribute is reported. const priorMainEvents: UsageEvent[] = []; + const priorSpans: Array<{ firstMs: number; lastMs: number }> = []; + // The prior-session events, by identity: the wall-clock sum below folds + // each session's own span, so the current session's must exclude them. + const priorEventSet = new Set(); let priorSessions = 0; - for (const id of priorSessionIds(planPath, env)) { + // Paths come from the shared accessor, which drops a symlinked prior + // directory: the ledger reads file CONTENT with no certification step, so + // it is the consumer a planted link would mislead most cheaply. + const priorDirs = new Map( + priorSessionDirs(planPath, env).map((p) => [p.sessionId, p]), + ); + for (const entry of priorSessionEntries(planPath, env)) { + const paths = priorDirs.get(entry.sessionId); let contributed = 0; + let events: UsageEvent[] = []; try { - const events = readUsage( - join(projectDir, 'chats', `${id}.jsonl`), + events = readUsage( + paths?.chatFile ?? + join(projectDir, 'chats', `${entry.sessionId}.jsonl`), floorMs, + entry.endsAtMs ?? undefined, ).events; priorMainEvents.push(...events); contributed += events.length; } catch { // The prior attempt's chat is lost; its agents may still count. } - const priorDir = join(projectDir, 'subagents', id); - try { - contributed += readAgentDir(priorDir, listAgentTranscriptFiles(priorDir)); - } catch { - // No prior agent dir is a real state (it died before launching any). + if (paths !== undefined) { + const before = agentEvents.length; + try { + contributed += readAgentDir( + paths.dir, + listAgentTranscriptFiles(paths.dir), + ); + } catch (err) { + // Absent is the legitimate state (the attempt died before launching + // anything). Any OTHER fault is disclosed rather than silently + // floored: the summary would otherwise announce that this session's + // cost is included while omitting all of its agents. + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + writeStderrLineSafe( + `WARNING: could not list the prior session's subagent transcripts at ` + + `${paths.dir} (${(err as NodeJS.ErrnoException)?.code ?? (err as Error).message}); ` + + `that attempt's agent cost is missing from this ledger.`, + ); + } + } + const priorAgentEvents = agentEvents.slice(before); + if (priorAgentEvents.length > 0) { + priorSpans.push(spanOf(priorAgentEvents)); + for (const e of priorAgentEvents) priorEventSet.add(e); + } + } + if (events.length > 0) { + priorSpans.push(spanOf(events)); + for (const e of events) priorEventSet.add(e); } if (contributed > 0) priorSessions++; } @@ -439,15 +497,21 @@ export function computeLedger( ...allMainEvents, ...agentEvents, ]); - const wallSeconds = - totals.firstAt !== null && totals.lastAt !== null - ? Math.max( - 0, - Math.round( - (Date.parse(totals.lastAt) - Date.parse(totals.firstAt)) / 1000, - ), - ) - : 0; + // The time this review SPENT, not the envelope it spans. On a resumed run + // the envelope would include the dead gap between the interrupted attempt + // and the continuation — minutes to hours of nothing — and the ledger + // renders this as "min wall" beside real token counts. Summing each + // session's own span is identical on a single-session run (one span) and + // honest on a resumed one. + const currentEvents = [...mainEvents, ...agentEvents].filter( + (e) => !priorEventSet.has(e), + ); + const spans = [...priorSpans]; + if (currentEvents.length > 0) spans.push(spanOf(currentEvents)); + const wallSeconds = spans.reduce( + (acc, sp) => acc + Math.max(0, Math.round((sp.lastMs - sp.firstMs) / 1000)), + 0, + ); const { id: _i, label: _l, ...totalsRest } = totals; return { diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 1ee2551939f..4a2dd7e7532 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -402,11 +402,16 @@ export function coverageFromTranscripts( // continues in a new session, and the interrupted attempt's evidence lives // under the session id the run ledger recorded. Same fence (the plan's // mtime), which a resume deliberately leaves untouched. + // `currentDirOptional`: a resumed continuation that recovered everything and + // launched nothing has no current-session dir yet (the harness creates it on + // the first launch), and this gate must read the prior attempt's evidence + // rather than refusing as broken infrastructure. Only ENOENT is absorbed. const records = readRunTranscripts( planPath, mtimeMs, env, plan.diffPathAbsolute, + { currentDirOptional: true }, ); const built = readRecordedPrompts(planPath); @@ -712,7 +717,13 @@ export function coverageFromTranscripts( const u = UNCOVERABLE_RE.exec(rec.finalText); if (u && chunk !== null && Number(u[1]) === chunk) { - uncoverable.add(chunk); + // The same supersession guard the sibling flags carry. Without it a + // stale declaration — a prior attempt's agent on a resumed run, or a + // relaunched agent's first try — permanently deletes live coverage + // below (`for (const id of uncoverable) covered.delete(id)` is + // post-loop and order-independent), so no compliant relaunch can ever + // clear it and the verdict caps on lines this run demonstrably read. + if (!superseded(rec, chunk)) uncoverable.add(chunk); continue; } @@ -982,6 +993,10 @@ export function coverageFromTranscripts( // whose launch verbatim-contains a CLI-built prompt and shows the brief or // the diff actually opened earns a count here. const certifies = (r: AgentRecord): boolean => { + // A record whose own return declares a chunk unreachable did not review + // it; counting it as recovered would have the body announce work + // "counted as reviewed" beside the gap that same record disclosed. + if (UNCOVERABLE_RE.test(r.finalText)) return false; const c = assignedChunk(r); if (c !== null) { const b = builtOf(`chunk-${c}`); @@ -1399,6 +1414,7 @@ export function verificationGaps( mtimeMs, env, plan.diffPathAbsolute, + { currentDirOptional: true }, ); const built = readRecordedPrompts(planPath); const gaps: VerificationReport['gaps'] = []; diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.ts index 75e9b86e912..d7b75714a4b 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.ts @@ -96,9 +96,13 @@ function readReverseAuditReturns( try { const since = statSync(planPath).mtimeMs; // Run-scoped: a resumed run's earlier auditors ran in a different session. - const auditors = readRunTranscripts(planPath, since, env, diffPath).filter( - (t) => t.launchPrompt.includes(REVERSE_AUDIT_IDENTITY), - ); + // `currentDirOptional`: without it a zero-launch resumed continuation + // throws here, the catch below reports `identityMatched: 0`, and the + // gate DEFERS to the reverse-audit-ran floor — failing open on exactly + // the layers the prior attempt never walked. + const auditors = readRunTranscripts(planPath, since, env, diffPath, { + currentDirOptional: true, + }).filter((t) => t.launchPrompt.includes(REVERSE_AUDIT_IDENTITY)); const corroborated = auditors .filter( (t) => diff --git a/packages/cli/src/commands/review/lib/retirement.ts b/packages/cli/src/commands/review/lib/retirement.ts index 6e91af026a0..892ef100eac 100644 --- a/packages/cli/src/commands/review/lib/retirement.ts +++ b/packages/cli/src/commands/review/lib/retirement.ts @@ -665,7 +665,13 @@ export function scheduleReverseAuditRound( // and their dry receipts are exactly what lets the continuation retire // territory instead of re-auditing it. The fence stays the plan's mtime, // which a resume deliberately leaves untouched. - const transcripts = readRunTranscripts(planPath, since, env, diffPath); + // `currentDirOptional`: a resumed run schedules its next round BEFORE + // launching any current-session agent, so its own transcript dir does not + // exist yet; without the option this throws and re-audits territory the + // prior attempt already retired. + const transcripts = readRunTranscripts(planPath, since, env, diffPath, { + currentDirOptional: true, + }); const built = readRecordedPrompts(planPath, since); // The prior-round records: one per (chunk, round) prompt this CLI built. diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 0c68df450ba..784fd6b97aa 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -15,8 +15,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, + lstatSync, realpathSync, rmSync, + statSync, + symlinkSync, writeFileSync, readFileSync, utimesSync, @@ -25,6 +28,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { appendRunSession, + priorSessionEntries, priorSessionIds, runSessionsPath, readResumeMarker, @@ -216,3 +220,74 @@ describe('marker dedup — a caller retry must not double-count', () => { expect(readResumeMarker(plan).restarts).toHaveLength(2); }); }); + +describe('the properties the threat model rests on', () => { + it('refuses the bare traversal ids `..` and `.`', () => { + // Today only the leading-alphanumeric rule stops these; pinning them + // means a regex refactor cannot quietly hand them to the path assembler. + for (const id of ['..', '.', './x', '..\\evil']) { + appendRunSession(plan, envOf(id)); + } + expect(priorSessionIds(plan, envOf('S9'))).toEqual([]); + }); + + it('applies the same charset gate to the resume marker on read', () => { + recordResume(plan, envOf('S2')); + const marker = JSON.parse(readFileSync(resumeMarkerPath(plan), 'utf8')); + marker.resumes.push({ sessionId: '../../etc', atMs: Date.now() }); + writeFileSync(resumeMarkerPath(plan), JSON.stringify(marker)); + expect(readResumeMarker(plan).resumes.map((r) => r.sessionId)).toEqual([ + 'S2', + ]); + }); + + it('deduplicates on READ, not only on append', () => { + // The file sits in a directory the orchestrator can reach; a duplicated + // entry would make the cost ledger bill one session twice. + appendRunSession(plan, envOf('S1')); + const raw = JSON.parse(readFileSync(runSessionsPath(plan), 'utf8')); + writeFileSync(runSessionsPath(plan), JSON.stringify([...raw, ...raw])); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('keeps a same-millisecond append inside the epoch fence', () => { + // fetch-pr writes the plan and appends two statements later: Date.now() + // floors to integer ms while mtimeMs is fractional, so without the slack + // a same-millisecond entry would read as older than its own run. + const mtimeMs = statSync(plan).mtimeMs; + appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs)); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('drops an entry older than the slack window', () => { + const mtimeMs = statSync(plan).mtimeMs; + appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs) - 3000); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('writes through a planted symlink without following it', () => { + // `noFollow: true` on both ledger writes: without it atomicWriteFileSync + // resolves the chain and the rename lands on the TARGET. + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + const target = join(root, 'outside.json'); + writeFileSync(target, '"untouched"'); + symlinkSync(target, runSessionsPath(plan)); + appendRunSession(plan, envOf('S1')); + expect(readFileSync(target, 'utf8')).toBe('"untouched"'); + expect(lstatSync(runSessionsPath(plan)).isSymbolicLink()).toBe(false); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('bounds each prior session by the next attempt start', () => { + // Inside the epoch fence: entries date from the plan's own capture on. + const base = Math.floor(statSync(plan).mtimeMs); + appendRunSession(plan, envOf('S0'), base); + appendRunSession(plan, envOf('S1'), base + 60_000); + expect(priorSessionEntries(plan, envOf('S2'))).toEqual([ + { sessionId: 'S0', atMs: base, endsAtMs: base + 60_000 }, + { sessionId: 'S1', atMs: base + 60_000, endsAtMs: null }, + ]); + }); +}); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 908eeea0386..8d06db7fefe 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -88,7 +88,7 @@ function readSessions(planPath: string): SessionEntry[] { ) as unknown; if (!Array.isArray(parsed)) return []; const epoch = runEpochMs(planPath); - return parsed.filter( + const kept = parsed.filter( (e): e is SessionEntry => typeof e === 'object' && e !== null && @@ -97,6 +97,14 @@ function readSessions(planPath: string): SessionEntry[] { typeof (e as SessionEntry).atMs === 'number' && (e as SessionEntry).atMs >= epoch, ); + // Deduplicate on READ, not only on append: the file lives in a directory + // the orchestrator can reach, and a hand-written duplicate would make a + // consumer that iterates entries (the cost ledger) bill one session + // twice. First occurrence wins — it carries the session's real start. + const seen = new Set(); + return kept.filter((e) => + seen.has(e.sessionId) ? false : (seen.add(e.sessionId), true), + ); } catch { return []; } @@ -138,10 +146,33 @@ export function priorSessionIds( planPath: string, env: NodeJS.ProcessEnv = process.env, ): string[] { + return priorSessionEntries(planPath, env).map((e) => e.sessionId); +} + +/** + * The same prior sessions, with the timestamps that bound them. + * + * `endsAtMs` is the NEXT ledger entry's `atMs` — the moment the following + * attempt started, which is the only end boundary this run records. The cost + * ledger clamps a prior session's chat usage to it: an interrupted session + * whose CLI kept being used for unrelated turns afterwards would otherwise + * bill that activity as review cost, the mirror of the omission the ledger + * exists to prevent. `null` when nothing followed it (it is the newest prior + * entry and the current session's own start is not recorded here). + */ +export function priorSessionEntries( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): Array<{ sessionId: string; atMs: number; endsAtMs: number | null }> { const current = env['QWEN_CODE_SESSION_ID']?.trim(); - return readSessions(planPath) - .map((e) => e.sessionId) - .filter((id) => id !== current); + const all = readSessions(planPath); + return all + .map((e, i) => ({ + sessionId: e.sessionId, + atMs: e.atMs, + endsAtMs: i + 1 < all.length ? all[i + 1].atMs : null, + })) + .filter((e) => e.sessionId !== current); } /** Resume/restart bookkeeping for one review run. */ @@ -194,6 +225,10 @@ export function readResumeMarker(planPath: string): ResumeMarker { typeof e === 'object' && e !== null && typeof e.sessionId === 'string' && + // Same closed charset as the session ledger: these ids have the + // same address semantics, and one read path applying the gate + // while the other does not is how a threat model rots. + SESSION_ID_RE.test(e.sessionId) && typeof e.atMs === 'number' && e.atMs >= epoch, ) diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index 767ad1220e9..0cc7749180b 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -12,8 +12,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { + chmodSync, mkdtempSync, rmSync, + symlinkSync, writeFileSync, mkdirSync, utimesSync, @@ -346,6 +348,9 @@ describe('readRunTranscripts — the run across its sessions', () => { it('lists the current session dir first, priors after, deduplicated', () => { const plan = planWithLedger('S0', 'S0', 'S1'); + // A prior session that exists on disk: the accessor skips a ledgered id + // whose directory is absent or symlinked, so the fixture must be real. + priorFile('S0', 'agent-a0.jsonl', transcript('a0')); expect(transcriptDirsForRun(plan, ENV)).toEqual([ join(dir, 'subagents', 'S1'), join(dir, 'subagents', 'S0'), @@ -400,3 +405,84 @@ describe('readRunTranscripts — currentDirOptional', () => { ).toThrow(TranscriptsUnavailableError); }); }); + +describe('readRunTranscripts — containment and fault handling', () => { + const transcript = (agentId: string): string => + JSON.stringify({ + agentId, + agentName: 'general-purpose', + type: 'user', + message: { role: 'user', parts: [{ text: `launch ${agentId}` }] }, + }) + '\n'; + + function planWithLedger(...sessionIds: string[]): string { + const plan = join(dir, 'qwen-review-pr-7-fetch.json'); + writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); + const recordDir = join(dir, 'qwen-review-pr-7-fetch-prompts'); + mkdirSync(recordDir, { recursive: true }); + writeFileSync( + join(recordDir, 'run-sessions.json'), + JSON.stringify( + sessionIds.map((id) => ({ sessionId: id, atMs: Date.now() })), + ), + ); + return plan; + } + + it('refuses a prior session whose directory is a symlink', () => { + // The ledger's charset gate stops `..`, but `subagents/` can BE a + // symlink and readdir/readFile follow one — that would let foreign + // transcripts enter as this run's prior evidence. + const plan = planWithLedger('S0', 'S1'); + const outside = join(dir, 'outside'); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, 'agent-foreign.jsonl'), transcript('foreign')); + symlinkSync(outside, join(dir, 'subagents', 'S0')); + mkdirSync(join(dir, 'subagents', 'S1'), { recursive: true }); + file('agent-a1.jsonl', transcript('a1')); + + const recs = readRunTranscripts(plan, undefined, ENV); + expect(recs.map((r) => r.agentId)).toEqual(['a1']); + expect(transcriptDirsForRun(plan, ENV)).toEqual([ + join(dir, 'subagents', 'S1'), + ]); + }); + + it('still throws when the current dir is unreadable, not merely absent', () => { + // EACCES on an EXISTING directory is a live fault; absorbing it would + // certify on prior evidence while the current records are unreadable. + const plan = planWithLedger('S0', 'S1'); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), + transcript('a0'), + ); + const cur = join(dir, 'subagents', 'S1'); + mkdirSync(cur, { recursive: true }); + chmodSync(cur, 0o000); + try { + expect(() => + readRunTranscripts(plan, undefined, ENV, undefined, { + currentDirOptional: true, + }), + ).toThrow(TranscriptsUnavailableError); + } finally { + chmodSync(cur, 0o755); + } + }); + + it('throws on a missing current dir when the run has no prior evidence', () => { + // No ledger: this is a run that has shown nothing, not a continuation. + const plan = join(dir, 'qwen-review-pr-7-fetch.json'); + writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); + expect(() => + readRunTranscripts( + plan, + undefined, + { QWEN_CODE_PROJECT_DIR: dir, QWEN_CODE_SESSION_ID: 'S-none' }, + undefined, + { currentDirOptional: true }, + ), + ).toThrow(TranscriptsUnavailableError); + }); +}); diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index def11130387..7b524f06bf9 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -37,7 +37,7 @@ // This module never takes a path from the model. The session id and project dir // come from the environment the CLI itself exported. -import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { ToolNames } from '@qwen-code/qwen-code-core'; import { join } from 'node:path'; import { priorSessionIds } from './run-ledger.js'; @@ -100,7 +100,14 @@ export interface AgentRecord { fromPriorSession?: boolean; } -/** Why no transcripts could be read. Never conflated with "the agents idled". */ +/** + * Why no transcripts could be read. Never conflated with "the agents idled". + * + * Carries the underlying readdir failure as `cause` where one exists, so a + * caller can distinguish "the directory does not exist yet" (ENOENT — the + * legitimate pre-launch state of a resumed run's own session) from a real + * infrastructure fault, which must never be absorbed. + */ export class TranscriptsUnavailableError extends Error {} /** @@ -384,6 +391,10 @@ export function readTranscripts( `no subagent transcripts at ${dir} (${(err as Error).message}). The ` + 'harness writes one per agent; if there are none, either no agents ran ' + 'or the harness could not write them.', + // The original errno travels with it: a caller that tolerates "the dir + // does not exist yet" must be able to tell that apart from EACCES/EIO, + // and the flattened message string cannot say which it was. + { cause: err }, ); } @@ -410,15 +421,54 @@ export function transcriptDirsForRun( planPath: string, env: NodeJS.ProcessEnv = process.env, ): string[] { - const { projectDir, dir } = transcriptPaths(env); + const { dir } = transcriptPaths(env); const dirs = [dir]; - for (const id of priorSessionIds(planPath, env)) { - const prior = join(projectDir, 'subagents', id); - if (!dirs.includes(prior)) dirs.push(prior); + for (const prior of priorSessionDirs(planPath, env)) { + if (!dirs.includes(prior.dir)) dirs.push(prior.dir); } return dirs; } +/** + * The EARLIER sessions of this run, as directories that are actually inside + * the harness's own tree. + * + * The ledger's charset gate keeps an id from traversing out with `..` or a + * separator, but `subagents/` can itself BE a symlink — and `readdirSync` + * and `readFileSync` follow one. That would defeat the containment this + * feature's threat model rests on ("a fabricated id can at most point a + * reader at a directory inside the harness's own subagents tree"), so a + * symlinked (or unstattable) prior directory is skipped: invisible evidence + * re-owes the work, which is the failure direction every reader here takes. + * + * Shared by every prior-session consumer — the transcript union and the cost + * ledger both assemble their paths from this, so the guard cannot be + * bypassed by a call site that builds its own `join`. + */ +export function priorSessionDirs( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): Array<{ sessionId: string; dir: string; chatFile: string }> { + const { projectDir } = transcriptPaths(env); + const out: Array<{ sessionId: string; dir: string; chatFile: string }> = []; + for (const sessionId of priorSessionIds(planPath, env)) { + const dir = join(projectDir, 'subagents', sessionId); + try { + if (lstatSync(dir).isSymbolicLink()) continue; + } catch { + // Absent (the attempt died before launching any agent) or unstattable: + // either way there is nothing here this run may read. + continue; + } + out.push({ + sessionId, + dir, + chatFile: join(projectDir, 'chats', `${sessionId}.jsonl`), + }); + } + return out; +} + /** * Every subagent THIS RUN launched, across all of the run's sessions. * @@ -436,9 +486,13 @@ export function transcriptDirsForRun( * `currentDirOptional` exists for exactly one caller shape: a resumed run * reading the PREVIOUS attempt's evidence before this session has launched * any agent — the harness creates `subagents/` on the first launch, - * so at that moment the current directory legitimately does not exist. A - * missing ENVIRONMENT (no session id, no project dir) still throws: that is - * an infrastructure fact whichever session it is. + * so at that moment the current directory legitimately does not exist. It is + * deliberately narrow on both axes: only ENOENT is absorbed (a permission or + * I/O fault on an existing directory is a live infrastructure fault), and + * only when this run actually has prior-session evidence to read instead — + * a run with no ledger and no directory has shown nothing, which is the + * infrastructure fact this module has always refused to certify past. A + * missing ENVIRONMENT (no session id, no project dir) still throws. */ export function readRunTranscripts( planPath: string, @@ -450,19 +504,34 @@ export function readRunTranscripts( // Validates the env first, so the optional-dir branch below can only ever // be absorbing "no directory yet", never "no environment". transcriptPaths(env); + const priors = priorSessionDirs(planPath, env); let out: AgentRecord[]; try { out = readTranscripts(since, env, diffPath); } catch (err) { + const code = ( + (err as { cause?: NodeJS.ErrnoException } | undefined)?.cause as + | NodeJS.ErrnoException + | undefined + )?.code; if ( !(err instanceof TranscriptsUnavailableError) || - opts.currentDirOptional !== true + opts.currentDirOptional !== true || + // ONLY the not-created-yet case. EACCES/EIO/ENOTDIR on an existing + // directory is a live infrastructure fault: absorbing it would let a + // run certify on prior-session evidence alone while the current + // session's records are unreadable and nothing says so. + code !== 'ENOENT' || + // ...and only when there IS prior evidence to read instead. With no + // ledgered session this is a run that has shown nothing, which every + // reader here must keep refusing to certify past. + priors.length === 0 ) { throw err; } out = []; } - for (const dir of transcriptDirsForRun(planPath, env).slice(1)) { + for (const { dir } of priors) { let names: string[]; try { names = listAgentTranscriptFiles(dir); From ea40030e180b27f2a2106720e4e7498c7317b3d9 Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 08:00:38 +0800 Subject: [PATCH 05/21] docs(review): say what the recoveredAgents bar actually is It is a strict subset of the live credit bars, not "the same bar": no drift rescues, because the count reports reuse and caps nothing, so it should under-claim rather than vouch for a delivery the pairing could not fully confirm. --- packages/cli/src/commands/review/lib/coverage.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 4a2dd7e7532..218f5cdd365 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -85,9 +85,18 @@ export interface CoverageFromTranscripts { /** * Agents whose certified work came from an EARLIER attempt's session — a * resumed run crediting the interrupted attempt's evidence. Zero on any run - * that never resumed. Counted only for records that clear the same bar as - * everyone else (verbatim-delivered CLI prompt plus an opened brief or an - * opened diff); reading the prior directory grants nothing by itself. + * that never resumed; reading the prior directory grants nothing by itself. + * + * The bar is a STRICT SUBSET of the live credit bars, deliberately: a + * verbatim-delivered CLI prompt plus an opened brief or diff, with none of + * the drift rescues. Those rescues exist so a run is not made to relaunch + * agents over a normalized word — they protect work this run can still + * see. This number only reports how much a continuation reused, it caps + * nothing (compose-review renders it as a non-capping note), so it should + * under-claim rather than announce reuse the pairing cannot fully vouch + * for. Coverage itself still applies its own rescue-inclusive bars to the + * same records, so nothing is under-credited where credit decides + * anything. */ recoveredAgents: number; /** From 5186ab88563b293ea406b4eea5b266110dec75eb Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 12:39:20 +0800 Subject: [PATCH 06/21] fix(review): bill a resumed run once, clamp its agents, and say what the pairing proves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five blockers from the round-3 review of the ledger PR. The cost ledger pushed TWO overlapping spans for a prior session — its chat window and the agent window nested inside it — so a resumed run's wall time double-counted the nested minutes; it now folds one span per session from the union. Prior-session agent transcripts were read with only the floor, so unrelated subagents launched in the interrupted CLI session after the review died billed to the review; readAgentDir now takes the same ceiling the chat already had. The two new chmod-000 fault tests get the repo's platform/root guard: on Windows chmod only toggles the read-only attribute and readdir still succeeds, which would have turned the test_windows merge-queue job red on this PR's own tests. And the module header's claim that the content-shaped pairing is something "fabrication cannot satisfy" was refuted by probe: an actor who can write into the harness tree can read a recorded prompt back and plant a matching transcript. The prose now states the guarantee that holds — it defeats an orchestrator fabricating its REPORT of the run, not an actor with write access to the tree, and that bar is the same one the current session's records have always cleared. Also from the same round: the ledger read paths refuse anything that is not a regular file (a planted FIFO blocked readFileSync forever while the write side was already noFollow-hardened), the epoch fence gained its upper half (a future-dated entry outlived every later rewrite), endsAtMs is derived in time order rather than file order so the cost clamp cannot invert, transcriptDirsForRun is gone (no production caller), and the tests gain the missing verdict assertion, the two zero-launch cases that actually exercise currentDirOptional at the retirement and layer-audit call sites, a findIndex-miss-proof ordering check, and a compose fixture that is no longer re-contaminated by base()'s eagerly evaluated default. --- .../commands/review/check-coverage.test.ts | 3 + .../commands/review/compose-review.test.ts | 26 ++-- .../src/commands/review/cost-ledger.test.ts | 113 +++++++++++++----- .../cli/src/commands/review/cost-ledger.ts | 29 +++-- .../cli/src/commands/review/fetch-pr.test.ts | 14 ++- .../review/lib/layer-audit-gate.test.ts | 11 ++ .../commands/review/lib/retirement.test.ts | 14 +++ .../commands/review/lib/run-ledger.test.ts | 40 ++++++- .../cli/src/commands/review/lib/run-ledger.ts | 60 ++++++++-- .../commands/review/lib/transcripts.test.ts | 58 ++++----- .../src/commands/review/lib/transcripts.ts | 21 ---- 11 files changed, 272 insertions(+), 117 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 397de941ccf..3c2b8788996 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2310,6 +2310,9 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' rmSync(join(dir, 'subagents', 'S1'), { recursive: true, force: true }); const r = coverageFromTranscripts(p, ENV); + // `ok` is the verdict that decides exit 0 vs exit 3 (relaunch + // everything) — the point of the continuation is that it does not. + expect(r.ok).toBe(true); expect(r.coveredChunks).toEqual([1, 2]); expect(r.recoveredAgents).toBeGreaterThanOrEqual(2); }); diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index 5a46d1fd01b..be1ed1b53a6 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -5823,9 +5823,19 @@ describe('composeReview — a resumed run is continuity, not a coverage gap', () }); describe('composeReview — continuity renders on every verdict', () => { - /** A resumed run: chunk-1's agent re-homed to the ledgered prior session. */ - function resumedPlan(): string { - const p = coveredPlan(); + /** + * A resumed run: chunk-1's agent re-homed to the ledgered prior session. + * + * `base()`'s object literal evaluates its `planPath: coveredPlan()` default + * even when the caller overrides it, and `coveredPlan()` REWRITES + * `subagents/S1/agent-a1.jsonl` — so the move must happen after `base()` + * has been built, not before. Callers pass the input through here. + */ + function resumedInput( + over: Partial = {}, + ): ComposeReviewInput { + const input = base(over); + const p = input.planPath as string; mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); renameSync( join(dir, 'subagents', 'S1', 'agent-a1.jsonl'), @@ -5838,21 +5848,17 @@ describe('composeReview — continuity renders on every verdict', () => { { sessionId: 'S1', atMs: Date.now() }, ]), ); - return p; + return input; } it('renders on REQUEST_CHANGES', () => { - const r = composeReview( - base({ planPath: resumedPlan(), criticalsInline: 1 }), - ); + const r = composeReview(resumedInput({ criticalsInline: 1 })); expect(r.event).toBe('REQUEST_CHANGES'); expect(r.body).toContain('Resumed run (not a gap): 1 agent result(s)'); }); it('renders on COMMENT', () => { - const r = composeReview( - base({ planPath: resumedPlan(), suggestionsInline: 1 }), - ); + const r = composeReview(resumedInput({ suggestionsInline: 1 })); expect(r.event).toBe('COMMENT'); expect(r.body).toContain('Resumed run (not a gap): 1 agent result(s)'); }); diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index 1ee99d4463c..9cc0a9d8c7d 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -1527,38 +1527,44 @@ describe('cost-ledger — prior-session bounds, faults and wall time', () => { expect(ledger.totals.inputTokens).toBe(1_500); }); - it('discloses an unreadable prior agent dir instead of silently flooring it', () => { - const { plan, env, project } = fixture(); - runLedger(project); - writeFileSync( - join(project, 'chats', 'S0.jsonl'), - event('2026-08-03T10:01:00Z', { input: 1_000, output: 100 }), - ); - const priorDir = join(project, 'subagents', 'S0'); - mkdirSync(priorDir, { recursive: true }); - chmodSync(priorDir, 0o000); - try { - const seen: string[] = []; - const spy = vi - .spyOn(process.stderr, 'write') - .mockImplementation((chunk: unknown) => { - seen.push(String(chunk)); - return true; - }); - let ledger; + // chmod 0o000 is a POSIX-only fault: on Windows it toggles the read-only + // attribute and readdir still succeeds, and root bypasses the mode + // entirely — the repo convention for this shape. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'discloses an unreadable prior agent dir instead of silently flooring it', + () => { + const { plan, env, project } = fixture(); + runLedger(project); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:01:00Z', { input: 1_000, output: 100 }), + ); + const priorDir = join(project, 'subagents', 'S0'); + mkdirSync(priorDir, { recursive: true }); + chmodSync(priorDir, 0o000); try { - ledger = computeLedger(plan, env); + const seen: string[] = []; + const spy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: unknown) => { + seen.push(String(chunk)); + return true; + }); + let ledger; + try { + ledger = computeLedger(plan, env); + } finally { + spy.mockRestore(); + } + expect(ledger.priorSessions).toBe(1); + expect( + seen.some((l) => l.includes("prior session's subagent transcripts")), + ).toBe(true); } finally { - spy.mockRestore(); + chmodSync(priorDir, 0o755); } - expect(ledger.priorSessions).toBe(1); - expect( - seen.some((l) => l.includes("prior session's subagent transcripts")), - ).toBe(true); - } finally { - chmodSync(priorDir, 0o755); - } - }); + }, + ); it('never reads a symlinked prior session directory', () => { const { plan, env, project } = fixture(); @@ -1579,6 +1585,55 @@ describe('cost-ledger — prior-session bounds, faults and wall time', () => { expect(ledger.totals.inputTokens).toBe(500); }); + it('counts a prior session ONCE when it had both chat and agents', () => { + // The agent window is nested inside the session's own; pushing both a + // chat span and an agent span billed the nested minutes twice. + const { plan, env, project } = fixture(); + runLedger(project); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + [ + event('2026-08-03T10:00:40Z', { input: 100, output: 10 }), + event('2026-08-03T10:02:40Z', { input: 100, output: 10 }), + ].join('\n'), + ); + mkdirSync(join(project, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(project, 'subagents', 'S0', 'agent-a0.jsonl'), + [ + userRecord('You are review agent `2` — Agent 2: Security.'), + event('2026-08-03T10:01:00Z', { input: 100, output: 10 }), + event('2026-08-03T10:02:00Z', { input: 100, output: 10 }), + ].join('\n'), + ); + writeFileSync( + join(project, 'chats', `${SESSION}.jsonl`), + event('2026-08-03T10:10:00Z', { input: 100, output: 10 }), + ); + + // Prior session spans 10:00:40 → 10:02:40 = 120s, not 120 + a nested 60. + expect(computeLedger(plan, env).totals.wallSeconds).toBe(120); + }); + + it("clamps a prior session's AGENT transcripts to the next attempt too", () => { + // The operator kept using the interrupted CLI session and its later + // subagents wrote into the same dir — the mirror harm the chat ceiling + // already forbids. + const { plan, env, project } = fixture(); + runLedger(project); + mkdirSync(join(project, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(project, 'subagents', 'S0', 'agent-a0.jsonl'), + [ + userRecord('You are review agent `2` — Agent 2: Security.'), + event('2026-08-03T10:02:00Z', { input: 2_000, output: 200 }), + event('2026-08-03T18:00:00Z', { input: 9_000, output: 900 }), + ].join('\n'), + ); + + expect(computeLedger(plan, env).totals.inputTokens).toBe(2_500); + }); + it("sums each session's own span rather than spanning the dead gap", () => { const { plan, env, project } = fixture(); runLedger(project); diff --git a/packages/cli/src/commands/review/cost-ledger.ts b/packages/cli/src/commands/review/cost-ledger.ts index 22fb7a06ff4..02a17f023b0 100644 --- a/packages/cli/src/commands/review/cost-ledger.ts +++ b/packages/cli/src/commands/review/cost-ledger.ts @@ -370,7 +370,11 @@ export function computeLedger( const agents: StreamCost[] = []; const agentEvents: UsageEvent[] = []; - const readAgentDir = (agentDir: string, names: string[]): number => { + const readAgentDir = ( + agentDir: string, + names: string[], + ceilingMs?: number, + ): number => { let streams = 0; for (const f of names) { const full = join(agentDir, f); @@ -387,7 +391,7 @@ export function computeLedger( if (mtimeMs < floorMs) continue; let read: { events: UsageEvent[]; launch: string }; try { - read = readUsage(full, floorMs); + read = readUsage(full, floorMs, ceilingMs); } catch { continue; // This agent's record is lost; the rest still count. } @@ -434,12 +438,17 @@ export function computeLedger( } catch { // The prior attempt's chat is lost; its agents may still count. } + let priorAgentEvents: UsageEvent[] = []; if (paths !== undefined) { const before = agentEvents.length; try { contributed += readAgentDir( paths.dir, listAgentTranscriptFiles(paths.dir), + // The same window the chat gets: an interrupted CLI session whose + // operator kept working would otherwise fold unrelated subagent + // cost into this review. + entry.endsAtMs ?? undefined, ); } catch (err) { // Absent is the legitimate state (the attempt died before launching @@ -454,15 +463,15 @@ export function computeLedger( ); } } - const priorAgentEvents = agentEvents.slice(before); - if (priorAgentEvents.length > 0) { - priorSpans.push(spanOf(priorAgentEvents)); - for (const e of priorAgentEvents) priorEventSet.add(e); - } + priorAgentEvents = agentEvents.slice(before); } - if (events.length > 0) { - priorSpans.push(spanOf(events)); - for (const e of events) priorEventSet.add(e); + // ONE span per session, from the union of its chat and agent events: the + // agent window is nested inside the session's, so pushing both would + // count the nested minutes twice. + const sessionEvents = [...events, ...priorAgentEvents]; + if (sessionEvents.length > 0) { + priorSpans.push(spanOf(sessionEvents)); + for (const e of sessionEvents) priorEventSet.add(e); } if (contributed > 0) priorSessions++; } diff --git a/packages/cli/src/commands/review/fetch-pr.test.ts b/packages/cli/src/commands/review/fetch-pr.test.ts index 050f373b53f..b5efb6ff9e8 100644 --- a/packages/cli/src/commands/review/fetch-pr.test.ts +++ b/packages/cli/src/commands/review/fetch-pr.test.ts @@ -747,12 +747,14 @@ describe('fetch-pr run-session ledger wiring', () => { // After the plan write: the entry must sit inside the run-epoch fence the // readers apply, which is keyed on the plan's mtime. const appendOrder = vi.mocked(appendRunSession).mock.invocationCallOrder[0]; - const writeOrder = producerMocks.writeFileSync.mock.invocationCallOrder.at( - producerMocks.writeFileSync.mock.calls.findIndex( - ([path]) => path === '/tmp/fetch-report.json', - ), + const writeIndex = producerMocks.writeFileSync.mock.calls.findIndex( + ([path]) => path === '/tmp/fetch-report.json', ); - expect(writeOrder).toBeDefined(); - expect(appendOrder).toBeGreaterThan(writeOrder as number); + // A findIndex miss returns -1, and `.at(-1)` would silently hand back an + // unrelated call's order — the assertion below would still pass. + expect(writeIndex).toBeGreaterThanOrEqual(0); + const writeOrder = + producerMocks.writeFileSync.mock.invocationCallOrder[writeIndex]; + expect(appendOrder).toBeGreaterThan(writeOrder); }); }); diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index a53e06f7997..ed7711d97cc 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -281,6 +281,17 @@ describe('the real reader on a resumed run — prior-session auditors count', () ); } + it('credits the prior attempt before this session has launched anything', () => { + // Without `currentDirOptional` the reader throws, the catch reports + // `identityMatched: 0`, and the gate DEFERS — failing open on exactly + // the layers the prior attempt never walked. + ledger('S0', 'S1'); + auditorTranscript('S0', ['lexing', 'expansion']); + rmSync(join(dir, 'subagents', 'S1'), { recursive: true, force: true }); + const out = layerAuditGate(plan, ENV()).unreviewed; + expect(out).toHaveLength(4); + }); + it("credits the interrupted attempt's walked layers through the ledger", () => { ledger('S0', 'S1'); auditorTranscript('S0', LAYERS); diff --git a/packages/cli/src/commands/review/lib/retirement.test.ts b/packages/cli/src/commands/review/lib/retirement.test.ts index 2b1a8c0f3ff..17b694d8024 100644 --- a/packages/cli/src/commands/review/lib/retirement.test.ts +++ b/packages/cli/src/commands/review/lib/retirement.test.ts @@ -1784,6 +1784,20 @@ describe('scheduleReverseAuditRound — a resumed run reads the prior attempt', ); } + it('reads the prior attempt before this session has launched anything', () => { + // The scheduler runs BEFORE the first launch of a resumed run, so the + // harness has not created `subagents/` yet — the exact shape + // `currentDirOptional` exists for. + ledger('S0', 'S1'); + for (const r of [1, 2]) { + transcriptIn('S0', record(r, 13, `chunk 13 round ${r} territory walk`)); + } + rmSync(join(dir, 'subagents', 'S1'), { recursive: true, force: true }); + const r3 = scheduleReverseAuditRound(plan, [13], 3, process.env, diff); + expect(r3.due).toEqual([]); + expect(r3.converged).toBe(true); + }); + it('retires a chunk on dry receipts the interrupted attempt earned', () => { ledger('S0', 'S1'); for (const r of [1, 2]) { diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 784fd6b97aa..1a164d47e6e 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -259,6 +259,41 @@ describe('the properties the threat model rests on', () => { expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); + it('drops a FUTURE-dated entry, which would outlive every rewrite', () => { + // One-sided fences let a hand-written far-future entry read as belonging + // to every later run of the same PR. + appendRunSession(plan, envOf('S1'), Date.now() + 3_600_000); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('orders the cost clamp by time, not by file order', () => { + const past = new Date(Date.now() - 300_000); + utimesSync(plan, past, past); + const base = Math.floor(statSync(plan).mtimeMs); + // Written out of order, as a hand-edited ledger or a backwards clock + // step between attempts would leave it. + appendRunSession(plan, envOf('S1'), base + 60_000); + appendRunSession(plan, envOf('S0'), base); + expect(priorSessionEntries(plan, envOf('S2'))).toEqual([ + { sessionId: 'S0', atMs: base, endsAtMs: base + 60_000 }, + { sessionId: 'S1', atMs: base + 60_000, endsAtMs: null }, + ]); + }); + + it('refuses a ledger path that is not a regular file', () => { + // A planted FIFO blocks readFileSync forever — a hang, not an error. + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + const target = join(root, 'elsewhere.json'); + writeFileSync( + target, + JSON.stringify([{ sessionId: 'X', atMs: Date.now() }]), + ); + symlinkSync(target, runSessionsPath(plan)); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + it('drops an entry older than the slack window', () => { const mtimeMs = statSync(plan).mtimeMs; appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs) - 3000); @@ -281,7 +316,10 @@ describe('the properties the threat model rests on', () => { }); it('bounds each prior session by the next attempt start', () => { - // Inside the epoch fence: entries date from the plan's own capture on. + // Inside BOTH halves of the fence: at or after the plan's capture, and + // not in the future. Backdate the plan so a later entry is still past. + const past = new Date(Date.now() - 300_000); + utimesSync(plan, past, past); const base = Math.floor(statSync(plan).mtimeMs); appendRunSession(plan, envOf('S0'), base); appendRunSession(plan, envOf('S1'), base + 60_000); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 8d06db7fefe..84e99d5b17c 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -25,7 +25,7 @@ // skill used to hold only in transcript memory: how many times this review has // resumed, and whether it already restarted once for head movement. -import { readFileSync, mkdirSync, statSync } from 'node:fs'; +import { readFileSync, lstatSync, mkdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { atomicWriteFileSync } from '@qwen-code/qwen-code-core'; import { promptRecordDir } from './prompt-record.js'; @@ -58,6 +58,14 @@ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; */ const RUN_EPOCH_SLACK_MS = 2000; +/** + * How far ahead of NOW an entry may be stamped. The fence's lower half keeps + * a previous review's entries out; without an upper half, one hand-written + * far-future entry survives every future rewrite of the same plan and is + * read as belonging to every later run. The same slack, mirrored. + */ +const FUTURE_SLACK_MS = 2000; + function runEpochMs(planPath: string): number { try { return statSync(planPath).mtimeMs - RUN_EPOCH_SLACK_MS; @@ -66,6 +74,11 @@ function runEpochMs(planPath: string): number { } } +/** Entries stamped past this are not this run's; nothing writes the future. */ +function runCeilingMs(nowMs: number = Date.now()): number { + return nowMs + FUTURE_SLACK_MS; +} + interface SessionEntry { sessionId: string; atMs: number; @@ -81,13 +94,30 @@ export function runSessionsPath(planPath: string): string { * the failure direction is "earlier evidence invisible", which coverage answers * by requiring the work again — never the reverse. */ +/** + * Read one ledger file, refusing anything that is not a regular file. + * + * The write side is hardened with `noFollow`; the read side must match, or a + * planted symlink redirects the read and a planted FIFO blocks it forever — + * a hang, not an error, in a command a review is waiting on. + */ +function readLedgerFile(path: string): string | null { + try { + if (!lstatSync(path).isFile()) return null; + return readFileSync(path, 'utf8'); + } catch { + return null; + } +} + function readSessions(planPath: string): SessionEntry[] { try { - const parsed = JSON.parse( - readFileSync(runSessionsPath(planPath), 'utf8'), - ) as unknown; + const raw = readLedgerFile(runSessionsPath(planPath)); + if (raw === null) return []; + const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; const epoch = runEpochMs(planPath); + const ceiling = runCeilingMs(); const kept = parsed.filter( (e): e is SessionEntry => typeof e === 'object' && @@ -95,7 +125,8 @@ function readSessions(planPath: string): SessionEntry[] { typeof (e as SessionEntry).sessionId === 'string' && SESSION_ID_RE.test((e as SessionEntry).sessionId) && typeof (e as SessionEntry).atMs === 'number' && - (e as SessionEntry).atMs >= epoch, + (e as SessionEntry).atMs >= epoch && + (e as SessionEntry).atMs <= ceiling, ); // Deduplicate on READ, not only on append: the file lives in a directory // the orchestrator can reach, and a hand-written duplicate would make a @@ -165,7 +196,11 @@ export function priorSessionEntries( env: NodeJS.ProcessEnv = process.env, ): Array<{ sessionId: string; atMs: number; endsAtMs: number | null }> { const current = env['QWEN_CODE_SESSION_ID']?.trim(); - const all = readSessions(planPath); + // Sort by time, not file order: `endsAtMs` is a COST CLAMP, and an + // out-of-order (hand-written) ledger or a backwards wall-clock step + // between attempts would otherwise invert it — a null or negative window + // silently unbounds or empties a prior session's bill. + const all = [...readSessions(planPath)].sort((a, b) => a.atMs - b.atMs); return all .map((e, i) => ({ sessionId: e.sessionId, @@ -207,9 +242,9 @@ export function resumeMarkerPath(planPath: string): string { */ export function readResumeMarker(planPath: string): ResumeMarker { try { - const parsed = JSON.parse( - readFileSync(resumeMarkerPath(planPath), 'utf8'), - ) as unknown; + const text = readLedgerFile(resumeMarkerPath(planPath)); + if (text === null) return emptyMarker(); + const parsed = JSON.parse(text) as unknown; if ( typeof parsed !== 'object' || parsed === null || @@ -218,6 +253,7 @@ export function readResumeMarker(planPath: string): ResumeMarker { return emptyMarker(); } const epoch = runEpochMs(planPath); + const ceiling = runCeilingMs(); const raw = parsed as ResumeMarker; const resumes = Array.isArray(raw.resumes) ? raw.resumes.filter( @@ -230,7 +266,8 @@ export function readResumeMarker(planPath: string): ResumeMarker { // while the other does not is how a threat model rots. SESSION_ID_RE.test(e.sessionId) && typeof e.atMs === 'number' && - e.atMs >= epoch, + e.atMs >= epoch && + e.atMs <= ceiling, ) : []; const restarts = Array.isArray(raw.restarts) @@ -240,7 +277,8 @@ export function readResumeMarker(planPath: string): ResumeMarker { e !== null && typeof e.reason === 'string' && typeof e.atMs === 'number' && - e.atMs >= epoch, + e.atMs >= epoch && + e.atMs <= ceiling, ) : []; return { schemaVersion: 1, resumes, restarts }; diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index 0cc7749180b..232237d575a 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -25,7 +25,7 @@ import { join } from 'node:path'; import { readTranscripts, readRunTranscripts, - transcriptDirsForRun, + priorSessionDirs, wasGivenTheDiff, transcriptDir, TranscriptsUnavailableError, @@ -346,13 +346,12 @@ describe('readRunTranscripts — the run across its sessions', () => { expect(recs.map((r) => r.agentId)).toEqual(['a1']); }); - it('lists the current session dir first, priors after, deduplicated', () => { + it('lists each prior session once, and only those that exist', () => { const plan = planWithLedger('S0', 'S0', 'S1'); // A prior session that exists on disk: the accessor skips a ledgered id // whose directory is absent or symlinked, so the fixture must be real. priorFile('S0', 'agent-a0.jsonl', transcript('a0')); - expect(transcriptDirsForRun(plan, ENV)).toEqual([ - join(dir, 'subagents', 'S1'), + expect(priorSessionDirs(plan, ENV).map((p) => p.dir)).toEqual([ join(dir, 'subagents', 'S0'), ]); }); @@ -443,33 +442,34 @@ describe('readRunTranscripts — containment and fault handling', () => { const recs = readRunTranscripts(plan, undefined, ENV); expect(recs.map((r) => r.agentId)).toEqual(['a1']); - expect(transcriptDirsForRun(plan, ENV)).toEqual([ - join(dir, 'subagents', 'S1'), - ]); + expect(priorSessionDirs(plan, ENV)).toEqual([]); }); - it('still throws when the current dir is unreadable, not merely absent', () => { - // EACCES on an EXISTING directory is a live fault; absorbing it would - // certify on prior evidence while the current records are unreadable. - const plan = planWithLedger('S0', 'S1'); - mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); - writeFileSync( - join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), - transcript('a0'), - ); - const cur = join(dir, 'subagents', 'S1'); - mkdirSync(cur, { recursive: true }); - chmodSync(cur, 0o000); - try { - expect(() => - readRunTranscripts(plan, undefined, ENV, undefined, { - currentDirOptional: true, - }), - ).toThrow(TranscriptsUnavailableError); - } finally { - chmodSync(cur, 0o755); - } - }); + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'still throws when the current dir is unreadable, not merely absent', + () => { + // EACCES on an EXISTING directory is a live fault; absorbing it would + // certify on prior evidence while the current records are unreadable. + const plan = planWithLedger('S0', 'S1'); + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + writeFileSync( + join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), + transcript('a0'), + ); + const cur = join(dir, 'subagents', 'S1'); + mkdirSync(cur, { recursive: true }); + chmodSync(cur, 0o000); + try { + expect(() => + readRunTranscripts(plan, undefined, ENV, undefined, { + currentDirOptional: true, + }), + ).toThrow(TranscriptsUnavailableError); + } finally { + chmodSync(cur, 0o755); + } + }, + ); it('throws on a missing current dir when the run has no prior evidence', () => { // No ledger: this is a run that has shown nothing, not a continuation. diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index 7b524f06bf9..eeeca1c0f12 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -408,27 +408,6 @@ export function readTranscripts( return out; } -/** - * Every transcript directory this RUN may have written to: the current - * session's, plus the directories of earlier attempts recorded in the run - * ledger (a resumed run continues in a new session; see `run-ledger.ts`). - * - * Directory names are assembled here from the env's project dir and the - * ledger's validated session ids — never taken from a caller. The current - * session's directory is always first. - */ -export function transcriptDirsForRun( - planPath: string, - env: NodeJS.ProcessEnv = process.env, -): string[] { - const { dir } = transcriptPaths(env); - const dirs = [dir]; - for (const prior of priorSessionDirs(planPath, env)) { - if (!dirs.includes(prior.dir)) dirs.push(prior.dir); - } - return dirs; -} - /** * The EARLIER sessions of this run, as directories that are actually inside * the harness's own tree. From c5a4de767341f4a31338ebdbc9ffac0d340276e9 Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 14:06:21 +0800 Subject: [PATCH 07/21] fix(review): refuse mid-flight credit, key the fence exactly, fold the ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A batch of findings that had gone untriaged (their threads were opened under the author account, so a reviewer-side filter had hidden them). A prior attempt's agent that died mid-flight — verbatim prompt, a logged diff read, no return — passed every coverage guard and marked its chunk covered, so the resumed run skipped the relaunch and the chunk's findings never existed anywhere. A prior-session record with an empty final text now credits nothing, in the coverage walk and in the recovered count alike. The current session keeps today's semantics: an empty return there is an agent still running, which the idle checks own. Session ids become PATH segments, so `s1` and `S1` are one directory on APFS and Windows: a case-variant ledger entry passed the string exclusion and re-read the current session as a prior one — every record twice, `recoveredAgents` minted on a run that never resumed, and the current chat folded into the prior totals. The exclusion and the dedup are now case-insensitive. The fresh-run fence was inexact by its own slack: a previous run that appended within it survived this run's plan write. Each entry now records the plan mtime it saw, and a reader keeps only entries that saw THIS plan — exact by construction, with the window as the fallback for entries written before the field existed. Also: the run-epoch fence had a verbatim private copy in three modules and now lives once in prompt-record.ts, and the prior-directory read no longer re-implements the files-to-records pipeline — one `recordsIn`, so a future record-level filter cannot apply to live evidence while bypassing recovered evidence. --- .../commands/review/check-coverage.test.ts | 17 +++++ .../cli/src/commands/review/lib/coverage.ts | 13 ++++ .../cli/src/commands/review/lib/deadline.ts | 45 ++--------- .../src/commands/review/lib/prompt-record.ts | 25 +++++++ .../commands/review/lib/run-ledger.test.ts | 25 ++++++- .../cli/src/commands/review/lib/run-ledger.ts | 75 +++++++++++++------ .../src/commands/review/lib/transcripts.ts | 24 +++++- 7 files changed, 155 insertions(+), 69 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 3c2b8788996..60b4fd5d8b2 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2297,6 +2297,23 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' expect(r.ok).toBe(false); }); + it('does NOT credit a prior agent that died mid-flight', () => { + // Verbatim prompt, a logged diff read, and no return: the session was + // killed before it reported. Crediting it would let the resumed run skip + // the relaunch and ship a chunk whose findings never existed anywhere. + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1dead', good(1), { calls: 2, text: '' }); + moveToSession('a1dead', 'S0'); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.coveredChunks).toEqual([2]); + expect(r.missingChunks).toEqual([1]); + expect(r.recoveredAgents).toBe(0); + expect(r.ok).toBe(false); + }); + it('credits the prior attempt when this session launched nothing at all', () => { // The zero-launch continuation: the harness creates subagents/ // on the first launch, so a run that recovered everything has no dir. diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 218f5cdd365..1ae796022d9 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -717,6 +717,16 @@ export function coverageFromTranscripts( budgetGaps.push({ agent: name, gaps }); } + // A PRIOR attempt's agent that never returned did not review anything. + // Its transcript can look complete — verbatim prompt, a diff read — yet + // its findings never existed, because the session died mid-flight; the + // resumed run would then skip the relaunch and ship the chunk unread. + // Only prior records take this bar: an empty final text in the CURRENT + // session is an agent still running (or a whiff the idle checks own), + // and a single-session run cannot reach this state at all — its crash + // rewrites the plan and fences the record out. + if (rec.fromPriorSession && rec.finalText.trim() === '') continue; + // What it was told to read, plus what it demonstrably read. The second // term is what lets an agent handed the bare diff path with no // territory — a reverse-audit pass, a verifier — be credited for @@ -1002,6 +1012,9 @@ export function coverageFromTranscripts( // whose launch verbatim-contains a CLI-built prompt and shows the brief or // the diff actually opened earns a count here. const certifies = (r: AgentRecord): boolean => { + // Same bar as the coverage walk: a prior agent that never returned did + // not finish, so it is not recovered work either. + if (r.finalText.trim() === '') return false; // A record whose own return declares a chunk unreachable did not review // it; counting it as recovered would have the body announce work // "counted as reviewed" beside the gap that same record disclosed. diff --git a/packages/cli/src/commands/review/lib/deadline.ts b/packages/cli/src/commands/review/lib/deadline.ts index d5f5ffb892f..177d3a20fbc 100644 --- a/packages/cli/src/commands/review/lib/deadline.ts +++ b/packages/cli/src/commands/review/lib/deadline.ts @@ -40,16 +40,10 @@ // still bounds the run, and a broken environment variable must degrade to // today's behaviour, not wedge every budgeted review at round 1. -import { - mkdirSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { parsePositiveIntegerEnv } from '@qwen-code/qwen-code-core'; -import { promptRecordDir } from './prompt-record.js'; +import { promptRecordDir, runEpochMs } from './prompt-record.js'; /** Unix seconds at which the review process will be killed. Set by CI. */ export const DEADLINE_ENV = 'QWEN_REVIEW_DEADLINE_EPOCH'; @@ -158,36 +152,11 @@ interface RoundStamp { const STAMPS_FILE = 'budget-rounds.json'; const STOP_FILE = 'budget-stop.json'; -/** - * Slack for the run-epoch fence below: absorbs the sub-millisecond skew - * between a file mtime (fractional) and `Date.now()` (integral) when a - * record is written moments after the plan. Real cross-run gaps are minutes - * to hours; two seconds is noise against them. - */ -const RUN_EPOCH_SLACK_MS = 2000; - -/** - * The run's epoch: records older than this predate the run and are ignored. - * - * The stamps and the stop marker key on the plan path, which is stable per - * PR — but every run rewrites the plan at its Step 1 capture (`fetch-pr` / - * `plan-diff` / `capture-local`), so the plan's own mtime dates the run. A - * budgeted run killed by the outer deadline leaves its records behind (the - * Step 9 cleanup never ran, and the workflow's start-of-run sweep removes - * worktrees, not these files); without the fence the next review of the - * same PR would price its rounds off the previous run's stamps (an - * hours-old stamp reads as an hours-long round and refuses round 1 of a - * fresh budget) and cap its verdict on a stop that did not happen in this - * run. An unstatable plan disables the fence — fail open, like every other - * malformed input this module reads. - */ -export function runEpochMs(planPath: string): number { - try { - return statSync(planPath).mtimeMs - RUN_EPOCH_SLACK_MS; - } catch { - return Number.NEGATIVE_INFINITY; - } -} +// The run-epoch fence is shared with every other per-run artifact (the +// prompt records, the transcripts, the session ledger) — one definition in +// `prompt-record.ts`, so a change to it cannot apply to some readers and not +// others. The stamps and the stop marker key on the plan path, which is +// stable per PR; its mtime dates the run. /** * The admission stamps written so far THIS RUN, oldest first. Unreadable → diff --git a/packages/cli/src/commands/review/lib/prompt-record.ts b/packages/cli/src/commands/review/lib/prompt-record.ts index c510fb7cb45..768c66928a0 100644 --- a/packages/cli/src/commands/review/lib/prompt-record.ts +++ b/packages/cli/src/commands/review/lib/prompt-record.ts @@ -44,6 +44,31 @@ import { writeStderrLineSafe } from '../../../utils/stdioHelpers.js'; * takes it as an argument. A path the model can choose is a path the model can * point somewhere flattering. */ +/** + * Slack for the run-epoch fence: absorbs the sub-millisecond skew between a + * file mtime (fractional) and `Date.now()` (integral) when a record is + * written moments after the plan. Real cross-run gaps are minutes to hours. + */ +export const RUN_EPOCH_SLACK_MS = 2000; + +/** + * The run's epoch: records older than this predate the run and are ignored. + * + * Every per-run artifact beside the plan keys on this — the deadline stamps, + * the prompt records, the transcripts, the session ledger — because the plan + * path is stable per PR while its mtime dates the run. One definition, so a + * change to the fence cannot apply to some readers and not others. An + * unstatable plan disables the fence (fail open, like every other malformed + * input these readers take). + */ +export function runEpochMs(planPath: string): number { + try { + return statSync(planPath).mtimeMs - RUN_EPOCH_SLACK_MS; + } catch { + return Number.NEGATIVE_INFINITY; + } +} + export function promptRecordDir(planPath: string): string { const p = resolve(planPath); return join(dirname(p), `${basename(p).replace(/\.json$/i, '')}-prompts`); diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 1a164d47e6e..f65cdbd73d6 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -98,13 +98,32 @@ describe('appendRunSession / priorSessionIds', () => { }); it('keeps entries when the plan is untouched — the resume case', () => { + // A resume does not rewrite the plan, so the entry's recorded plan mtime + // still matches. (Backdating the plan here would be a DIFFERENT plan + // state, which the exact fresh-run boundary is right to reject — the + // rewrite case has its own test below.) appendRunSession(plan, envOf('S1')); - // Simulate time passing without a plan rewrite: entries stay visible. - const past = new Date(Date.now() - 3600_000); - utimesSync(plan, past, past); expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); + it('drops an entry written against a DIFFERENT plan state', () => { + // The window's slack is inexact by construction: a previous run that + // appended within it survives the fence. The recorded plan mtime is the + // exact boundary — a fresh run rewrites the plan, a resume does not. + appendRunSession(plan, envOf('S0')); + const later = new Date(Date.now() + 1000); + utimesSync(plan, later, later); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('reads a case-variant of the current session as the SAME session', () => { + // These ids become path segments; on APFS/Windows `s1` and `S1` are one + // directory, so treating a variant as a prior session double-reads every + // record this run wrote and mints a resume that never happened. + appendRunSession(plan, envOf('s1')); + expect(priorSessionIds(plan, envOf('S1'))).toEqual([]); + }); + it('reads a corrupt ledger as empty', () => { appendRunSession(plan, envOf('S1')); writeFileSync(runSessionsPath(plan), '{not json'); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 84e99d5b17c..466ac1d7295 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -28,7 +28,7 @@ import { readFileSync, lstatSync, mkdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { atomicWriteFileSync } from '@qwen-code/qwen-code-core'; -import { promptRecordDir } from './prompt-record.js'; +import { promptRecordDir, runEpochMs } from './prompt-record.js'; const SESSIONS_FILE = 'run-sessions.json'; const RESUME_FILE = 'resume.json'; @@ -49,28 +49,30 @@ export const RESUME_MAX = 2; const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; /** - * Same fence, same constant, same reason as `deadline.ts`'s `runEpochMs`: the - * ledger keys on the plan path, which is stable per PR, but every FRESH run - * rewrites the plan — so entries older than the plan's mtime belong to a - * previous review of the same PR and must be invisible. A resumed run - * deliberately does not rewrite the plan, which is exactly what keeps the - * first attempt's entries inside the fence. + * How far ahead of NOW an entry may be stamped. The epoch fence's lower half + * keeps a previous review's entries out; without an upper half, one + * hand-written far-future entry survives every future rewrite of the same + * plan and is read as belonging to every later run. */ -const RUN_EPOCH_SLACK_MS = 2000; +const FUTURE_SLACK_MS = 2000; /** - * How far ahead of NOW an entry may be stamped. The fence's lower half keeps - * a previous review's entries out; without an upper half, one hand-written - * far-future entry survives every future rewrite of the same plan and is - * read as belonging to every later run. The same slack, mirrored. + * The plan mtime an entry was written against — the EXACT fresh-run boundary. + * + * The epoch window alone is inexact by its own slack: a previous run that + * appended within the slack of this run's plan write survives it, and one of + * its late transcripts would then be credited here. An entry carries the + * mtime it saw, and a reader keeps only entries that saw THIS plan — which a + * fresh run necessarily rewrote and a resumed run deliberately did not. + * Entries without the field (written before it existed) fall back to the + * window, so an in-flight upgrade degrades rather than erasing a resumable + * run's ledger. */ -const FUTURE_SLACK_MS = 2000; - -function runEpochMs(planPath: string): number { +function planMtimeMs(planPath: string): number | null { try { - return statSync(planPath).mtimeMs - RUN_EPOCH_SLACK_MS; + return statSync(planPath).mtimeMs; } catch { - return Number.NEGATIVE_INFINITY; + return null; } } @@ -82,6 +84,8 @@ function runCeilingMs(nowMs: number = Date.now()): number { interface SessionEntry { sessionId: string; atMs: number; + /** The plan mtime this entry was written against; absent on old files. */ + planMtimeMs?: number; } /** Where the session ledger lives — derived from the plan path, never passed. */ @@ -118,6 +122,7 @@ function readSessions(planPath: string): SessionEntry[] { if (!Array.isArray(parsed)) return []; const epoch = runEpochMs(planPath); const ceiling = runCeilingMs(); + const planMtime = planMtimeMs(planPath); const kept = parsed.filter( (e): e is SessionEntry => typeof e === 'object' && @@ -126,16 +131,28 @@ function readSessions(planPath: string): SessionEntry[] { SESSION_ID_RE.test((e as SessionEntry).sessionId) && typeof (e as SessionEntry).atMs === 'number' && (e as SessionEntry).atMs >= epoch && - (e as SessionEntry).atMs <= ceiling, + (e as SessionEntry).atMs <= ceiling && + // The exact boundary when the entry carries one: an entry written + // against a DIFFERENT plan belongs to a different run, whatever the + // window says. The window's own slack is inexact by construction — + // a previous run that appended within it survives otherwise. + (typeof (e as SessionEntry).planMtimeMs !== 'number' || + planMtime === null || + (e as SessionEntry).planMtimeMs === planMtime), ); // Deduplicate on READ, not only on append: the file lives in a directory // the orchestrator can reach, and a hand-written duplicate would make a // consumer that iterates entries (the cost ledger) bill one session // twice. First occurrence wins — it carries the session's real start. + // Case-insensitively, because these ids become PATH segments: on APFS or + // Windows `s1` and `S1` are the same directory, so a case-variant entry + // would otherwise read as a second session and double-count everything + // inside it. const seen = new Set(); - return kept.filter((e) => - seen.has(e.sessionId) ? false : (seen.add(e.sessionId), true), - ); + return kept.filter((e) => { + const k = e.sessionId.toLowerCase(); + return seen.has(k) ? false : (seen.add(k), true); + }); } catch { return []; } @@ -157,7 +174,12 @@ export function appendRunSession( if (!id || !SESSION_ID_RE.test(id)) return; const entries = readSessions(planPath); if (entries.some((e) => e.sessionId === id)) return; - entries.push({ sessionId: id, atMs: nowMs }); + const mtime = planMtimeMs(planPath); + entries.push({ + sessionId: id, + atMs: nowMs, + ...(mtime === null ? {} : { planMtimeMs: mtime }), + }); const dir = promptRecordDir(planPath); mkdirSync(dir, { recursive: true }); atomicWriteFileSync(runSessionsPath(planPath), JSON.stringify(entries), { @@ -195,7 +217,12 @@ export function priorSessionEntries( planPath: string, env: NodeJS.ProcessEnv = process.env, ): Array<{ sessionId: string; atMs: number; endsAtMs: number | null }> { - const current = env['QWEN_CODE_SESSION_ID']?.trim(); + // Case-insensitive for the same reason the dedup is: a case-variant of the + // CURRENT session id resolves to the current session's own directory, so + // reading it as a prior session double-reads every record this run wrote — + // minting `recoveredAgents` and a resumed disclosure on a run that never + // resumed, and folding the current chat into the prior totals. + const current = env['QWEN_CODE_SESSION_ID']?.trim().toLowerCase(); // Sort by time, not file order: `endsAtMs` is a COST CLAMP, and an // out-of-order (hand-written) ledger or a backwards wall-clock step // between attempts would otherwise invert it — a null or negative window @@ -207,7 +234,7 @@ export function priorSessionEntries( atMs: e.atMs, endsAtMs: i + 1 < all.length ? all[i + 1].atMs : null, })) - .filter((e) => e.sessionId !== current); + .filter((e) => e.sessionId.toLowerCase() !== current); } /** Resume/restart bookkeeping for one review run. */ diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index eeeca1c0f12..f90281c44e3 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -398,6 +398,25 @@ export function readTranscripts( ); } + return recordsIn(dir, names, since, diffPath); +} + +/** + * The files-to-records pipeline, in ONE place. + * + * Both readers below walk it — the current session's directory and each + * prior session's — so a record-level filter or validation added here cannot + * apply to live evidence while silently bypassing recovered evidence, which + * is precisely the evidence a fabrication concern is about. The callers keep + * only the policy that genuinely differs between them: throw versus skip on + * an unreadable directory. + */ +function recordsIn( + dir: string, + names: string[], + since: number | undefined, + diffPath: string | undefined, +): AgentRecord[] { const out: AgentRecord[] = []; for (const name of names) { const rec = parseTranscript(join(dir, name), diffPath); @@ -517,10 +536,7 @@ export function readRunTranscripts( } catch { continue; // Earlier attempt's evidence invisible → its work is re-owed. } - for (const name of names) { - const rec = parseTranscript(join(dir, name), diffPath); - if (!rec) continue; - if (since !== undefined && rec.mtimeMs < since) continue; + for (const rec of recordsIn(dir, names, since, diffPath)) { rec.fromPriorSession = true; out.push(rec); } From a67a2db3642104b173176650d42963fd9ed0914a Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 16:11:45 +0800 Subject: [PATCH 08/21] fix(review): gate prior evidence on an authorized resume, and bound what it reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round's central finding: the ledger is an address book, not permission. Any session pointing at an old plan could union the ledgered attempts' transcripts and inherit their coverage — after head drift, stale evidence could certify code nobody reviewed. Reading prior evidence now requires this session's own entry in the resume marker, which `fetch-pr --resume` writes only after every probe passed. One gate, in the accessor every reader goes through. With it, several narrower holes close too. A transcript is checked against the session that owns its directory, so a record COPIED into another attempt's directory cannot earn that attempt's credit. Each prior attempt's evidence is bounded above by the moment the next attempt began, so a session that kept running after the resume took over is no longer credited to the review. The exact fresh-run fence loses its legacy fallback — the field ships in the same change as the ledger, so there are no older files to be lenient toward, and the fallback was the only way a previous run's entry could survive a plan rewrite. And both ledger files are bounded in bytes and entries before they are parsed: a planted huge or duplicated ledger could otherwise stall every command that touches bookkeeping, or spend the resume cap. The fixtures now build their state with the real writers instead of hand-written JSON, which is why several of them changed shape: a hand-written ledger has no plan stamp, and a re-homed transcript that keeps its old session id is exactly the misplaced shape production now refuses. --- .../commands/review/check-coverage.test.ts | 62 +++++++++++--- .../commands/review/compose-review.test.ts | 56 ++++++------ .../src/commands/review/cost-ledger.test.ts | 47 ++++++---- .../review/lib/layer-audit-gate.test.ts | 16 +++- .../commands/review/lib/retirement.test.ts | 16 +++- .../commands/review/lib/run-ledger.test.ts | 41 +++++++++ .../cli/src/commands/review/lib/run-ledger.ts | 85 ++++++++++++++----- .../commands/review/lib/transcripts.test.ts | 47 ++++++---- .../src/commands/review/lib/transcripts.ts | 59 +++++++++++-- 9 files changed, 324 insertions(+), 105 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 60b4fd5d8b2..56a2133ca8f 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -25,7 +25,6 @@ import { mkdirSync, utimesSync, readdirSync, - renameSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -41,6 +40,7 @@ import { } from './lib/prompt-record.js'; import { requiredAgents, type RosterPlan } from './lib/roster.js'; import { checkCoverageCommand } from './check-coverage.js'; +import { appendRunSession, recordResume } from './lib/run-ledger.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; // Only the stderr test below drives the command handler; the rest of this file @@ -2167,19 +2167,37 @@ describe('coverage — a resumed run credits the prior attempt through the ledge function ledger(planPath: string, ...ids: string[]): void { const d = promptRecordDir(planPath); mkdirSync(d, { recursive: true }); - writeFileSync( - join(d, 'run-sessions.json'), - JSON.stringify(ids.map((id) => ({ sessionId: id, atMs: Date.now() }))), + // Written by the real writer: it stamps the plan mtime each entry is + // keyed on, and the resume marker is what authorizes reading prior + // evidence at all. The current attempt is stamped last, since each + // attempt's window closes when the next one opened. + const nowMs = Date.now(); + ids.forEach((id, i) => + appendRunSession( + planPath, + { QWEN_CODE_SESSION_ID: id }, + i === ids.length - 1 ? nowMs + 1500 : nowMs, + ), ); + recordResume(planPath, ENV, nowMs + 1500); } /** Re-home a transcript written by `transcript()` into another session. */ function moveToSession(id: string, session: string): void { mkdirSync(join(dir, 'subagents', session), { recursive: true }); - renameSync( - join(dir, 'subagents', 'S1', `agent-${id}.jsonl`), - join(dir, 'subagents', session, `agent-${id}.jsonl`), + // Re-stamp the records with the session that now owns them: a + // transcript COPIED into another session's directory is not that + // session's evidence, and production refuses the misplaced shape. + const from = join(dir, 'subagents', 'S1', `agent-${id}.jsonl`); + const to = join(dir, 'subagents', session, `agent-${id}.jsonl`); + writeFileSync( + to, + readFileSync(from, 'utf8').replaceAll( + '"sessionId":"S1"', + `"sessionId":"${session}"`, + ), ); + rmSync(from, { force: true }); } it('passes 3D on work the interrupted attempt completed, and discloses it', () => { @@ -2245,18 +2263,36 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' function ledger(planPath: string, ...ids: string[]): void { const d = promptRecordDir(planPath); mkdirSync(d, { recursive: true }); - writeFileSync( - join(d, 'run-sessions.json'), - JSON.stringify(ids.map((id) => ({ sessionId: id, atMs: Date.now() }))), + // Written by the real writer: it stamps the plan mtime each entry is + // keyed on, and the resume marker is what authorizes reading prior + // evidence at all. The current attempt is stamped last, since each + // attempt's window closes when the next one opened. + const nowMs = Date.now(); + ids.forEach((id, i) => + appendRunSession( + planPath, + { QWEN_CODE_SESSION_ID: id }, + i === ids.length - 1 ? nowMs + 1500 : nowMs, + ), ); + recordResume(planPath, ENV, nowMs + 1500); } function moveToSession(id: string, session: string): void { mkdirSync(join(dir, 'subagents', session), { recursive: true }); - renameSync( - join(dir, 'subagents', 'S1', `agent-${id}.jsonl`), - join(dir, 'subagents', session, `agent-${id}.jsonl`), + // Re-stamp the records with the session that now owns them: a + // transcript COPIED into another session's directory is not that + // session's evidence, and production refuses the misplaced shape. + const from = join(dir, 'subagents', 'S1', `agent-${id}.jsonl`); + const to = join(dir, 'subagents', session, `agent-${id}.jsonl`); + writeFileSync( + to, + readFileSync(from, 'utf8').replaceAll( + '"sessionId":"S1"', + `"sessionId":"${session}"`, + ), ); + rmSync(from, { force: true }); } it('a superseded prior-attempt declaration does not delete the chunk it covers', () => { diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index be1ed1b53a6..0657374e249 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -8,7 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, readFileSync, - renameSync, writeFileSync, mkdirSync, rmSync, @@ -18,6 +17,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { createHash } from 'node:crypto'; import { promptRecordDir, briefPath } from './lib/prompt-record.js'; +import { appendRunSession, recordResume } from './lib/run-ledger.js'; import { writeBudgetStop, writeRoundCapStop } from './lib/deadline.js'; import { getGhHost, setGhHost } from './lib/gh.js'; import { parseLedger } from './lib/ledger.js'; @@ -340,6 +340,34 @@ function transcript( ); } +/** + * Move one agent's transcript into a ledgered PRIOR session — the shape a + * resumed run reads. + * + * The records are re-stamped with the owning session (a transcript copied + * into another session's directory is not that session's evidence, and + * production refuses the misplaced shape), and the ledger is written by the + * real writer so the entries carry the plan mtime they are keyed on. The + * current attempt is stamped last and its resume recorded: reading prior + * evidence at all requires that authorization. + */ +function rehomeToPriorSession(planPath: string, file: string): void { + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + const from = join(dir, 'subagents', 'S1', file); + writeFileSync( + join(dir, 'subagents', 'S0', file), + readFileSync(from, 'utf8').replaceAll( + '"sessionId":"S1"', + '"sessionId":"S0"', + ), + ); + rmSync(from, { force: true }); + const now = Date.now(); + appendRunSession(planPath, { QWEN_CODE_SESSION_ID: 'S0' }, now); + appendRunSession(planPath, { QWEN_CODE_SESSION_ID: 'S1' }, now + 1500); + recordResume(planPath, ENV, now + 1500); +} + /** * A prompt the CLI would have built: it names the diff and the read of THIS * chunk's lines. The offsets are the chunk's own, as `agent-prompt` emits them — @@ -5801,18 +5829,7 @@ describe('composeReview — a resumed run is continuity, not a coverage gap', () // a capping entry here downgraded every clean resumed run to COMMENT, // permanently, since the prior records never leave the ledger. const p = coveredPlan(); - mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); - renameSync( - join(dir, 'subagents', 'S1', 'agent-a1.jsonl'), - join(dir, 'subagents', 'S0', 'agent-a1.jsonl'), - ); - writeFileSync( - join(promptRecordDir(p), 'run-sessions.json'), - JSON.stringify([ - { sessionId: 'S0', atMs: Date.now() }, - { sessionId: 'S1', atMs: Date.now() }, - ]), - ); + rehomeToPriorSession(p, 'agent-a1.jsonl'); const r = composeReview(base({ planPath: p })); expect(r.event).toBe('APPROVE'); @@ -5836,18 +5853,7 @@ describe('composeReview — continuity renders on every verdict', () => { ): ComposeReviewInput { const input = base(over); const p = input.planPath as string; - mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); - renameSync( - join(dir, 'subagents', 'S1', 'agent-a1.jsonl'), - join(dir, 'subagents', 'S0', 'agent-a1.jsonl'), - ); - writeFileSync( - join(promptRecordDir(p), 'run-sessions.json'), - JSON.stringify([ - { sessionId: 'S0', atMs: Date.now() }, - { sessionId: 'S1', atMs: Date.now() }, - ]), - ); + rehomeToPriorSession(p, 'agent-a1.jsonl'); return input; } diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index 9cc0a9d8c7d..51d4a343cda 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -23,6 +23,7 @@ import { renderLedger, costLedgerCommand, } from './cost-ledger.js'; +import { appendRunSession, recordResume } from './lib/run-ledger.js'; const SESSION = 'S-ledger'; @@ -1383,14 +1384,21 @@ describe('cost-ledger — a resumed run bills the whole review', () => { /** The ledger `fetch-pr` writes, naming the interrupted attempt S0. */ function runLedger(plan: string, project: string): void { - const d = join(project, 'plan-prompts'); - mkdirSync(d, { recursive: true }); - writeFileSync( - join(d, 'run-sessions.json'), - JSON.stringify([ - { sessionId: 'S0', atMs: Date.parse('2026-08-03T10:00:30Z') }, - { sessionId: SESSION, atMs: Date.parse('2026-08-03T10:09:00Z') }, - ]), + void project; + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: 'S0' }, + Date.parse('2026-08-03T10:00:30Z'), + ); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: SESSION }, + Date.parse('2026-08-03T10:09:00Z'), + ); + recordResume( + plan, + { QWEN_CODE_SESSION_ID: SESSION }, + Date.parse('2026-08-03T10:09:00Z'), ); } @@ -1495,14 +1503,21 @@ describe('cost-ledger — prior-session bounds, faults and wall time', () => { project: string, resumedAt = '2026-08-03T10:09:00Z', ): void { - const d = join(project, 'plan-prompts'); - mkdirSync(d, { recursive: true }); - writeFileSync( - join(d, 'run-sessions.json'), - JSON.stringify([ - { sessionId: 'S0', atMs: Date.parse('2026-08-03T10:00:30Z') }, - { sessionId: SESSION, atMs: Date.parse(resumedAt) }, - ]), + const plan = join(project, 'plan.json'); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: 'S0' }, + Date.parse('2026-08-03T10:00:30Z'), + ); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: SESSION }, + Date.parse(resumedAt), + ); + recordResume( + plan, + { QWEN_CODE_SESSION_ID: SESSION }, + Date.parse(resumedAt), ); } diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index ed7711d97cc..32d0d3a977a 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { layerAuditGate } from './layer-audit-gate.js'; +import { appendRunSession, recordResume } from './run-ledger.js'; import { MODELED_SYSTEM_DOMAIN } from './audit-layers.js'; /** A valid RepositoryContext (strict schema) with the given domains. */ @@ -211,10 +212,19 @@ describe('the real reader on a resumed run — prior-session auditors count', () }); function ledger(...ids: string[]): void { - writeFileSync( - join(dir, 'plan-prompts', 'run-sessions.json'), - JSON.stringify(ids.map((id) => ({ sessionId: id, atMs: Date.now() }))), + // Written by the real writer: it stamps the plan mtime each entry is + // keyed on, and the resume marker is what authorizes reading prior + // evidence at all. The current attempt is stamped last, since each + // attempt's window closes when the next one opened. + const nowMs = Date.now(); + ids.forEach((id, i) => + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: id }, + i === ids.length - 1 ? nowMs + 1500 : nowMs, + ), ); + recordResume(plan, ENV(), nowMs + 1500); } /** A corroborated reverse auditor in `session`: identity line, a baked diff --git a/packages/cli/src/commands/review/lib/retirement.test.ts b/packages/cli/src/commands/review/lib/retirement.test.ts index 17b694d8024..79c14c77916 100644 --- a/packages/cli/src/commands/review/lib/retirement.test.ts +++ b/packages/cli/src/commands/review/lib/retirement.test.ts @@ -16,6 +16,7 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { scheduleReverseAuditRound } from './retirement.js'; +import { appendRunSession, recordResume } from './run-ledger.js'; import { promptRecordDir, recordPrompt, @@ -1778,10 +1779,19 @@ describe('scheduleReverseAuditRound — a resumed run reads the prior attempt', function ledger(...ids: string[]): void { const d = promptRecordDir(plan); mkdirSync(d, { recursive: true }); - writeFileSync( - join(d, 'run-sessions.json'), - JSON.stringify(ids.map((id) => ({ sessionId: id, atMs: Date.now() }))), + // Written by the real writer: it stamps the plan mtime each entry is + // keyed on, and the resume marker is what authorizes reading prior + // evidence at all. The current attempt is stamped last, since each + // attempt's window closes when the next one opened. + const nowMs = Date.now(); + ids.forEach((id, i) => + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: id }, + i === ids.length - 1 ? nowMs + 1500 : nowMs, + ), ); + recordResume(plan, process.env, nowMs + 1500); } it('reads the prior attempt before this session has launched anything', () => { diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index f65cdbd73d6..5ffb77c04cc 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -46,6 +46,16 @@ const envOf = (sessionId: string): NodeJS.ProcessEnv => ({ QWEN_CODE_SESSION_ID: sessionId, }); +/** + * Authorize `sessionId` to read prior evidence — what `fetch-pr --resume` + * records once every probe has passed. Reading the ledger's prior entries at + * all requires it, so a test about ledger CONTENT states that precondition + * explicitly rather than relying on its absence. + */ +function authorize(sessionId: string, atMs: number = Date.now()): void { + recordResume(plan, envOf(sessionId), atMs); +} + beforeEach(() => { root = realpathSync(mkdtempSync(join(tmpdir(), 'run-ledger-'))); plan = join(root, 'qwen-review-pr-7-fetch.json'); @@ -56,11 +66,13 @@ afterEach(() => rmSync(root, { recursive: true, force: true })); describe('appendRunSession / priorSessionIds', () => { it('records a session and surfaces it to a LATER session as prior', () => { appendRunSession(plan, envOf('S1')); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); it('excludes the current session from its own priors', () => { appendRunSession(plan, envOf('S1')); + authorize('S1'); expect(priorSessionIds(plan, envOf('S1'))).toEqual([]); }); @@ -68,6 +80,7 @@ describe('appendRunSession / priorSessionIds', () => { appendRunSession(plan, envOf('S1')); appendRunSession(plan, envOf('S1')); appendRunSession(plan, envOf('S2')); + authorize('S3'); expect(priorSessionIds(plan, envOf('S3'))).toEqual(['S1', 'S2']); }); @@ -75,6 +88,7 @@ describe('appendRunSession / priorSessionIds', () => { appendRunSession(plan, envOf('../evil')); appendRunSession(plan, envOf('a/b')); appendRunSession(plan, envOf('')); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); @@ -87,6 +101,7 @@ describe('appendRunSession / priorSessionIds', () => { ) as Array>; raw.push({ sessionId: '../../etc', atMs: Date.now() }); writeFileSync(runSessionsPath(plan), JSON.stringify(raw)); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); @@ -94,6 +109,7 @@ describe('appendRunSession / priorSessionIds', () => { appendRunSession(plan, envOf('S0'), Date.now() - 60_000); // Rewriting the plan advances the run epoch past the stale entry. writeFileSync(plan, JSON.stringify({ diffLines: 2, chunks: [] })); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); @@ -103,6 +119,7 @@ describe('appendRunSession / priorSessionIds', () => { // state, which the exact fresh-run boundary is right to reject — the // rewrite case has its own test below.) appendRunSession(plan, envOf('S1')); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); @@ -113,7 +130,18 @@ describe('appendRunSession / priorSessionIds', () => { appendRunSession(plan, envOf('S0')); const later = new Date(Date.now() + 1000); utimesSync(plan, later, later); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('reads NO prior sessions until this session was authorized to resume', () => { + // The ledger is an address book, not permission. Without the marker any + // session pointing at an old plan would inherit the ledgered attempts' + // evidence — after head drift, stale work could certify unreviewed code. + appendRunSession(plan, envOf('S1')); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); it('reads a case-variant of the current session as the SAME session', () => { @@ -121,22 +149,26 @@ describe('appendRunSession / priorSessionIds', () => { // directory, so treating a variant as a prior session double-reads every // record this run wrote and mints a resume that never happened. appendRunSession(plan, envOf('s1')); + authorize('S1'); expect(priorSessionIds(plan, envOf('S1'))).toEqual([]); }); it('reads a corrupt ledger as empty', () => { appendRunSession(plan, envOf('S1')); writeFileSync(runSessionsPath(plan), '{not json'); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); it('reads a non-array ledger as empty', () => { appendRunSession(plan, envOf('S1')); writeFileSync(runSessionsPath(plan), JSON.stringify({ sessionId: 'S1' })); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); it('reads a missing ledger as empty', () => { + authorize('S1'); expect(priorSessionIds(plan, envOf('S1'))).toEqual([]); }); @@ -247,6 +279,7 @@ describe('the properties the threat model rests on', () => { for (const id of ['..', '.', './x', '..\\evil']) { appendRunSession(plan, envOf(id)); } + authorize('S9'); expect(priorSessionIds(plan, envOf('S9'))).toEqual([]); }); @@ -266,6 +299,7 @@ describe('the properties the threat model rests on', () => { appendRunSession(plan, envOf('S1')); const raw = JSON.parse(readFileSync(runSessionsPath(plan), 'utf8')); writeFileSync(runSessionsPath(plan), JSON.stringify([...raw, ...raw])); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); @@ -275,6 +309,7 @@ describe('the properties the threat model rests on', () => { // a same-millisecond entry would read as older than its own run. const mtimeMs = statSync(plan).mtimeMs; appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs)); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); @@ -282,6 +317,7 @@ describe('the properties the threat model rests on', () => { // One-sided fences let a hand-written far-future entry read as belonging // to every later run of the same PR. appendRunSession(plan, envOf('S1'), Date.now() + 3_600_000); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); @@ -293,6 +329,7 @@ describe('the properties the threat model rests on', () => { // step between attempts would leave it. appendRunSession(plan, envOf('S1'), base + 60_000); appendRunSession(plan, envOf('S0'), base); + authorize('S2'); expect(priorSessionEntries(plan, envOf('S2'))).toEqual([ { sessionId: 'S0', atMs: base, endsAtMs: base + 60_000 }, { sessionId: 'S1', atMs: base + 60_000, endsAtMs: null }, @@ -310,12 +347,14 @@ describe('the properties the threat model rests on', () => { JSON.stringify([{ sessionId: 'X', atMs: Date.now() }]), ); symlinkSync(target, runSessionsPath(plan)); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); it('drops an entry older than the slack window', () => { const mtimeMs = statSync(plan).mtimeMs; appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs) - 3000); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); @@ -331,6 +370,7 @@ describe('the properties the threat model rests on', () => { appendRunSession(plan, envOf('S1')); expect(readFileSync(target, 'utf8')).toBe('"untouched"'); expect(lstatSync(runSessionsPath(plan)).isSymbolicLink()).toBe(false); + authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); @@ -342,6 +382,7 @@ describe('the properties the threat model rests on', () => { const base = Math.floor(statSync(plan).mtimeMs); appendRunSession(plan, envOf('S0'), base); appendRunSession(plan, envOf('S1'), base + 60_000); + authorize('S2'); expect(priorSessionEntries(plan, envOf('S2'))).toEqual([ { sessionId: 'S0', atMs: base, endsAtMs: base + 60_000 }, { sessionId: 'S1', atMs: base + 60_000, endsAtMs: null }, diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 466ac1d7295..dde419d0cd6 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -105,9 +105,19 @@ export function runSessionsPath(planPath: string): string { * planted symlink redirects the read and a planted FIFO blocks it forever — * a hang, not an error, in a command a review is waiting on. */ +const MAX_LEDGER_BYTES = 256 * 1024; +const MAX_LEDGER_ENTRIES = 64; + function readLedgerFile(path: string): string | null { try { - if (!lstatSync(path).isFile()) return null; + const st = lstatSync(path); + // Not a regular file: a symlink would redirect the read and a FIFO would + // block it forever — a hang, not an error, in a command a review waits on. + if (!st.isFile()) return null; + // Bounded before the read: these files are bookkeeping (a handful of + // small entries), and a planted multi-gigabyte one would otherwise stall + // or exhaust every command that touches them. + if (st.size > MAX_LEDGER_BYTES) return null; return readFileSync(path, 'utf8'); } catch { return null; @@ -123,7 +133,9 @@ function readSessions(planPath: string): SessionEntry[] { const epoch = runEpochMs(planPath); const ceiling = runCeilingMs(); const planMtime = planMtimeMs(planPath); - const kept = parsed.filter( + // Cap the entry count too: the byte bound alone still admits tens of + // thousands of tiny entries, each of which costs a directory read. + const kept = parsed.slice(0, MAX_LEDGER_ENTRIES).filter( (e): e is SessionEntry => typeof e === 'object' && e !== null && @@ -132,13 +144,16 @@ function readSessions(planPath: string): SessionEntry[] { typeof (e as SessionEntry).atMs === 'number' && (e as SessionEntry).atMs >= epoch && (e as SessionEntry).atMs <= ceiling && - // The exact boundary when the entry carries one: an entry written - // against a DIFFERENT plan belongs to a different run, whatever the - // window says. The window's own slack is inexact by construction — - // a previous run that appended within it survives otherwise. - (typeof (e as SessionEntry).planMtimeMs !== 'number' || - planMtime === null || - (e as SessionEntry).planMtimeMs === planMtime), + // The exact boundary, with no fallback: an entry written against a + // DIFFERENT plan belongs to a different run, whatever the window + // says, and an entry that cannot say which plan it saw cannot be + // placed at all. The window alone is inexact by construction — a + // previous run that appended within its slack survives it — and the + // field ships in the same change as the ledger itself, so there are + // no older files to be lenient toward. + typeof (e as SessionEntry).planMtimeMs === 'number' && + planMtime !== null && + (e as SessionEntry).planMtimeMs === planMtime, ); // Deduplicate on READ, not only on append: the file lives in a directory // the orchestrator can reach, and a hand-written duplicate would make a @@ -213,6 +228,28 @@ export function priorSessionIds( * exists to prevent. `null` when nothing followed it (it is the newest prior * entry and the current session's own start is not recorded here). */ +/** + * Did the CURRENT session actually earn the right to read prior evidence? + * + * The ledger is an address book; it does not say a resume was authorized. + * Without this gate any session that points at an old plan unions the + * ledgered attempts' transcripts and inherits their coverage — after head + * drift, stale evidence could certify code nobody reviewed. `fetch-pr + * --resume` records the resume only after every probe passed (worktree at + * the fetched SHA and clean, diff bytes unchanged, live head unmoved), so + * the marker naming this session IS that proof, written by the CLI. + */ +function resumeAuthorized( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const current = env['QWEN_CODE_SESSION_ID']?.trim().toLowerCase(); + if (!current) return false; + return readResumeMarker(planPath).resumes.some( + (r) => r.sessionId.toLowerCase() === current, + ); +} + export function priorSessionEntries( planPath: string, env: NodeJS.ProcessEnv = process.env, @@ -222,6 +259,7 @@ export function priorSessionEntries( // reading it as a prior session double-reads every record this run wrote — // minting `recoveredAgents` and a resumed disclosure on a run that never // resumed, and folding the current chat into the prior totals. + if (!resumeAuthorized(planPath, env)) return []; const current = env['QWEN_CODE_SESSION_ID']?.trim().toLowerCase(); // Sort by time, not file order: `endsAtMs` is a COST CLAMP, and an // out-of-order (hand-written) ledger or a backwards wall-clock step @@ -282,8 +320,9 @@ export function readResumeMarker(planPath: string): ResumeMarker { const epoch = runEpochMs(planPath); const ceiling = runCeilingMs(); const raw = parsed as ResumeMarker; + const seenResume = new Set(); const resumes = Array.isArray(raw.resumes) - ? raw.resumes.filter( + ? raw.resumes.slice(0, MAX_LEDGER_ENTRIES).filter( (e) => typeof e === 'object' && e !== null && @@ -294,19 +333,25 @@ export function readResumeMarker(planPath: string): ResumeMarker { SESSION_ID_RE.test(e.sessionId) && typeof e.atMs === 'number' && e.atMs >= epoch && - e.atMs <= ceiling, + e.atMs <= ceiling && + // Duplicates would each consume a RESUME_MAX slot and refuse a + // legitimate continuation. + !seenResume.has(e.sessionId.toLowerCase()) && + (seenResume.add(e.sessionId.toLowerCase()), true), ) : []; const restarts = Array.isArray(raw.restarts) - ? raw.restarts.filter( - (e) => - typeof e === 'object' && - e !== null && - typeof e.reason === 'string' && - typeof e.atMs === 'number' && - e.atMs >= epoch && - e.atMs <= ceiling, - ) + ? raw.restarts + .slice(0, MAX_LEDGER_ENTRIES) + .filter( + (e) => + typeof e === 'object' && + e !== null && + typeof e.reason === 'string' && + typeof e.atMs === 'number' && + e.atMs >= epoch && + e.atMs <= ceiling, + ) : []; return { schemaVersion: 1, resumes, restarts }; } catch { diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index 232237d575a..f55d26aab4f 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -31,6 +31,7 @@ import { TranscriptsUnavailableError, type AgentRecord, } from './transcripts.js'; +import { appendRunSession, recordResume } from './run-ledger.js'; let dir: string; let ENV: NodeJS.ProcessEnv; @@ -238,6 +239,7 @@ describe('wasGivenTheDiff', () => { const rec = (launchPrompt: string): AgentRecord => ({ agentId: 'a', agentName: 'general-purpose', + recordedSession: '', launchPrompt, successfulToolCalls: 0, diffToolCalls: 0, @@ -282,12 +284,18 @@ describe('readRunTranscripts — the run across its sessions', () => { writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); const recordDir = join(dir, 'qwen-review-pr-7-fetch-prompts'); mkdirSync(recordDir, { recursive: true }); - writeFileSync( - join(recordDir, 'run-sessions.json'), - JSON.stringify( - sessionIds.map((id) => ({ sessionId: id, atMs: Date.now() })), - ), - ); + // Prior attempts first, the current one stamped slightly later: each + // attempt's window closes when the next one opened, and a fixture that + // stamped them together would fence out transcripts written "now". + const now = Date.now(); + sessionIds.forEach((id, i) => { + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: id }, + i === sessionIds.length - 1 ? now + 1500 : now, + ); + }); + recordResume(plan, ENV, now + 1500); return plan; } @@ -369,12 +377,8 @@ describe('readRunTranscripts — currentDirOptional', () => { it('absorbs a missing CURRENT dir when asked — the pre-launch resume read', () => { const plan = join(dir, 'qwen-review-pr-7-fetch.json'); writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); - const recordDir = join(dir, 'qwen-review-pr-7-fetch-prompts'); - mkdirSync(recordDir, { recursive: true }); - writeFileSync( - join(recordDir, 'run-sessions.json'), - JSON.stringify([{ sessionId: 'S0', atMs: Date.now() }]), - ); + const now = Date.now(); + appendRunSession(plan, { QWEN_CODE_SESSION_ID: 'S0' }, now); mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); writeFileSync( join(dir, 'subagents', 'S0', 'agent-a0.jsonl'), @@ -382,6 +386,10 @@ describe('readRunTranscripts — currentDirOptional', () => { ); const env = { QWEN_CODE_PROJECT_DIR: dir, QWEN_CODE_SESSION_ID: 'S-new' }; + // Reading prior evidence requires this session's own authorized resume, + // stamped after the prior attempt's transcripts. + appendRunSession(plan, env, now + 1500); + recordResume(plan, env, now + 1500); // Without the option: the current dir is still load-bearing. expect(() => readRunTranscripts(plan, undefined, env)).toThrow( TranscriptsUnavailableError, @@ -419,12 +427,15 @@ describe('readRunTranscripts — containment and fault handling', () => { writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); const recordDir = join(dir, 'qwen-review-pr-7-fetch-prompts'); mkdirSync(recordDir, { recursive: true }); - writeFileSync( - join(recordDir, 'run-sessions.json'), - JSON.stringify( - sessionIds.map((id) => ({ sessionId: id, atMs: Date.now() })), - ), - ); + const now = Date.now(); + sessionIds.forEach((id, i) => { + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: id }, + i === sessionIds.length - 1 ? now + 1500 : now, + ); + }); + recordResume(plan, ENV, now + 1500); return plan; } diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index f90281c44e3..d88b2e76e51 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -40,7 +40,7 @@ import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { ToolNames } from '@qwen-code/qwen-code-core'; import { join } from 'node:path'; -import { priorSessionIds } from './run-ledger.js'; +import { priorSessionEntries } from './run-ledger.js'; /** One subagent, as the harness recorded it. */ export interface AgentRecord { @@ -88,6 +88,14 @@ export interface AgentRecord { * read. */ successfulReadFileArgs: string[]; + /** + * The session the harness stamped on the records, when it stamped one. + * Compared against the directory that supplied the file: a transcript + * COPIED into another session's directory is not that session's evidence, + * and on the resume path a copy could otherwise earn recovered coverage + * for an attempt that never ran it. + */ + recordedSession: string; /** The agent's own final text, as the harness saw it. */ finalText: string; /** When the transcript was last written. */ @@ -225,6 +233,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { let agentId = ''; let agentName = ''; + let recordedSession = ''; let launchPrompt = ''; let finalText = ''; let successfulToolCalls = 0; @@ -259,6 +268,9 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { if (!agentName && typeof rec['agentName'] === 'string') { agentName = rec['agentName']; } + if (!recordedSession && typeof rec['sessionId'] === 'string') { + recordedSession = rec['sessionId']; + } const type = rec['type']; @@ -339,6 +351,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { return { agentId, agentName, + recordedSession, launchPrompt, successfulToolCalls, diffToolCalls, @@ -416,12 +429,29 @@ function recordsIn( names: string[], since: number | undefined, diffPath: string | undefined, + opts: { sessionId?: string; until?: number } = {}, ): AgentRecord[] { const out: AgentRecord[] = []; for (const name of names) { const rec = parseTranscript(join(dir, name), diffPath); if (!rec) continue; if (since !== undefined && rec.mtimeMs < since) continue; + // Each attempt's window closes when the next one opened: a session that + // kept running after the resume took over is no longer this review's, + // and its later transcripts must not be credited to it. + if (opts.until !== undefined && rec.mtimeMs >= opts.until) continue; + // The record must belong to the directory that supplied it. The harness + // stamps the session on its records; a file that names a DIFFERENT one + // was copied there, and a copy is not evidence of the attempt whose + // directory it sits in. A record with no stamp is accepted — older + // harness writes carry none — but a mismatch is refused. + if ( + opts.sessionId !== undefined && + rec.recordedSession !== '' && + rec.recordedSession.toLowerCase() !== opts.sessionId.toLowerCase() + ) { + continue; + } out.push(rec); } return out; @@ -446,10 +476,21 @@ function recordsIn( export function priorSessionDirs( planPath: string, env: NodeJS.ProcessEnv = process.env, -): Array<{ sessionId: string; dir: string; chatFile: string }> { +): Array<{ + sessionId: string; + dir: string; + chatFile: string; + /** When the NEXT attempt began — this one's upper window. */ + endsAtMs: number | null; +}> { const { projectDir } = transcriptPaths(env); - const out: Array<{ sessionId: string; dir: string; chatFile: string }> = []; - for (const sessionId of priorSessionIds(planPath, env)) { + const out: Array<{ + sessionId: string; + dir: string; + chatFile: string; + endsAtMs: number | null; + }> = []; + for (const { sessionId, endsAtMs } of priorSessionEntries(planPath, env)) { const dir = join(projectDir, 'subagents', sessionId); try { if (lstatSync(dir).isSymbolicLink()) continue; @@ -462,6 +503,7 @@ export function priorSessionDirs( sessionId, dir, chatFile: join(projectDir, 'chats', `${sessionId}.jsonl`), + endsAtMs, }); } return out; @@ -529,14 +571,17 @@ export function readRunTranscripts( } out = []; } - for (const { dir } of priors) { + for (const prior of priors) { let names: string[]; try { - names = listAgentTranscriptFiles(dir); + names = listAgentTranscriptFiles(prior.dir); } catch { continue; // Earlier attempt's evidence invisible → its work is re-owed. } - for (const rec of recordsIn(dir, names, since, diffPath)) { + for (const rec of recordsIn(prior.dir, names, since, diffPath, { + sessionId: prior.sessionId, + until: prior.endsAtMs ?? undefined, + })) { rec.fromPriorSession = true; out.push(rec); } From e6260cafadc6c34c26a18239536fca30d1ab6c45 Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 16:22:33 +0800 Subject: [PATCH 09/21] fix(review): drop unfinished prior records at the source, and not count superseded ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same principle. A prior attempt's agent that never returned is excluded from the record set EVERY gate reads, not just from the coverage walk — the same record would otherwise still satisfy roster matching and the Step 4/5 delivery floor, and a gate reading a different evidence set than its siblings is how a review certifies work nobody did. And `recoveredAgents` no longer counts a prior record whose obligation a current relaunch already satisfied: the count is what the continuity note reports, so claiming recovery for work this run re-did would misdescribe it. The compose fixture that hid this is fixed too — `base()`'s literal evaluates its `coveredPlan()` default even when the caller overrides `planPath`, which re-created the current-session record the test had just re-homed. --- .../commands/review/check-coverage.test.ts | 15 ++++++++++ .../commands/review/compose-review.test.ts | 13 +++++--- .../cli/src/commands/review/lib/coverage.ts | 30 +++++++++++-------- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 56a2133ca8f..5d0bdc8f44e 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2333,6 +2333,21 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' expect(r.ok).toBe(false); }); + it('does not count prior work a current relaunch superseded', () => { + // The count is what the continuity note reports; claiming recovery for + // an obligation this run re-did would misdescribe what it reused. + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1old', good(1), { calls: 2 }); + moveToSession('a1old', 'S0'); + transcript('a1', good(1), { calls: 3 }); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.ok).toBe(true); + expect(r.recoveredAgents).toBe(0); + }); + it('does NOT credit a prior agent that died mid-flight', () => { // Verbatim prompt, a logged diff read, and no return: the session was // killed before it reported. Crediting it would let the resumed run skip diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index 0657374e249..3d9115f3a6e 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -5828,10 +5828,15 @@ describe('composeReview — a resumed run is continuity, not a coverage gap', () // recovered work COUNTS as reviewed: no cap, no "Not reviewed:" entry — // a capping entry here downgraded every clean resumed run to COMMENT, // permanently, since the prior records never leave the ledger. - const p = coveredPlan(); - rehomeToPriorSession(p, 'agent-a1.jsonl'); - - const r = composeReview(base({ planPath: p })); + // Build the input FIRST: `base()`'s object literal evaluates its + // `planPath: coveredPlan()` default even when the caller overrides it, + // and `coveredPlan()` rewrites the current session's chunk-1 record — + // which would then supersede the prior one and (correctly) stop counting + // as recovered work. + const input = base({}); + rehomeToPriorSession(input.planPath as string, 'agent-a1.jsonl'); + + const r = composeReview(input); expect(r.event).toBe('APPROVE'); expect(r.body).toContain('Resumed run (not a gap): 1 agent result(s)'); expect(r.body).not.toContain('Not reviewed: review continuity'); diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 1ae796022d9..cd6f8492189 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -415,13 +415,23 @@ export function coverageFromTranscripts( // launched nothing has no current-session dir yet (the harness creates it on // the first launch), and this gate must read the prior attempt's evidence // rather than refusing as broken infrastructure. Only ENOENT is absorbed. - const records = readRunTranscripts( + const allRecords = readRunTranscripts( planPath, mtimeMs, env, plan.diffPathAbsolute, { currentDirOptional: true }, ); + // A PRIOR attempt's agent that never returned did not review anything: the + // session died mid-flight, so its findings never existed. Dropped HERE, at + // the source, rather than in the coverage walk alone — the same record + // would otherwise still satisfy roster matching and the Step 4/5 delivery + // floor, and a gate reading a different evidence set than its siblings is + // how a review certifies work nobody did. An empty return in the CURRENT + // session is an agent still running, which the idle checks own. + const records = allRecords.filter( + (r) => !(r.fromPriorSession && r.finalText.trim() === ''), + ); const built = readRecordedPrompts(planPath); const blindAgents: string[] = []; @@ -717,16 +727,6 @@ export function coverageFromTranscripts( budgetGaps.push({ agent: name, gaps }); } - // A PRIOR attempt's agent that never returned did not review anything. - // Its transcript can look complete — verbatim prompt, a diff read — yet - // its findings never existed, because the session died mid-flight; the - // resumed run would then skip the relaunch and ship the chunk unread. - // Only prior records take this bar: an empty final text in the CURRENT - // session is an agent still running (or a whiff the idle checks own), - // and a single-session run cannot reach this state at all — its crash - // rewrites the plan and fences the record out. - if (rec.fromPriorSession && rec.finalText.trim() === '') continue; - // What it was told to read, plus what it demonstrably read. The second // term is what lets an agent handed the bare diff path with no // territory — a reverse-audit pass, a verifier — be credited for @@ -1046,7 +1046,13 @@ export function coverageFromTranscripts( // non-capping continuity note, beside the other disclosed-but-not-capping // blocks (deferred lint, test-plan notes). const recoveredAgents = records.filter( - (r) => r.fromPriorSession && certifies(r), + (r) => + r.fromPriorSession && + certifies(r) && + // Not if a CURRENT record already satisfied the same obligation: the + // count is what the continuity note reports, and announcing recovery + // for superseded work would misdescribe what this run reused. + !superseded(r, assignedChunk(r)), ).length; return { From 1004c217c7c2434c95f282249ac56a29a81faddd Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 16:34:12 +0800 Subject: [PATCH 10/21] fix(review): bill each attempt from its own start, and require delivery for a receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cost floor was the plan's mtime for every stream, so a review that began inside an existing CLI session billed that session's earlier, unrelated turns — and on a resumed run each prior attempt was floored the same way. Each stream is now floored at the moment its session became a review attempt, from its own ledger entry, and a stream that exists but cannot be read is counted and disclosed rather than silently skipped: a lower total must not be presented as a complete one. The layer-audit gate now requires a receipt-contributing auditor to have been launched with the CLI's own recorded prompt and to have opened the brief it points at. Territory alone let a compliant sibling satisfy the floor while a hand-written auditor supplied the claims. repo-context skips its enrichment write entirely when the serialized plan is unchanged — the common resumed case, where the commit-then-restore window simply cannot open — and when it does write, a failure to restore the plan's timestamp is reported instead of silently advancing the run epoch out from under this run's own evidence. Tests: verificationGaps is pinned directly on the zero-launch continuation (evidence only in a prior session, no current transcript dir), the resumed-run ledger line is asserted in both numbers and by its absence, and the compose auditor fixture now delivers the recorded prompt it always claimed to. --- .../commands/review/check-coverage.test.ts | 30 +++++++ .../commands/review/compose-review.test.ts | 16 +++- .../src/commands/review/cost-ledger.test.ts | 84 +++++++++++++++++++ .../cli/src/commands/review/cost-ledger.ts | 35 ++++++-- .../review/lib/layer-audit-gate.test.ts | 42 +++++++++- .../commands/review/lib/layer-audit-gate.ts | 20 +++++ .../cli/src/commands/review/lib/run-ledger.ts | 20 +++++ .../cli/src/commands/review/repo-context.ts | 29 ++++++- 8 files changed, 265 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 5d0bdc8f44e..42e7a02a4f5 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2259,6 +2259,36 @@ describe('coverage — a resumed run credits the prior attempt through the ledge }); }); +describe('verificationGaps — a resumed run reads the prior attempt', () => { + it('accepts Step 4/5 evidence that exists only in a prior session', () => { + // The zero-launch continuation, pinned at the verification floor rather + // than inferred from its coverage sibling: a current-session-only reader + // regressing here would report the steps as never run. + const p = plan(); + for (const name of readdirSync(join(dir, 'subagents', 'S1'))) { + mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); + const from = join(dir, 'subagents', 'S1', name); + writeFileSync( + join(dir, 'subagents', 'S0', name), + readFileSync(from, 'utf8').replaceAll( + '"sessionId":"S1"', + '"sessionId":"S0"', + ), + ); + rmSync(from, { force: true }); + } + const now = Date.now(); + appendRunSession(p, { QWEN_CODE_SESSION_ID: 'S0' }, now); + appendRunSession(p, { QWEN_CODE_SESSION_ID: 'S1' }, now + 1500); + recordResume(p, ENV, now + 1500); + rmSync(join(dir, 'subagents', 'S1'), { recursive: true, force: true }); + + const r = verificationGaps(p, { postsFindings: true }, ENV); + expect(r.gaps.map((g) => g.subject)).not.toContain('verification'); + expect(r.gaps.map((g) => g.subject)).not.toContain('reverse audit'); + }); +}); + describe('coverage — a stale Uncoverable declaration cannot cap live coverage', () => { function ledger(planPath: string, ...ids: string[]): void { const d = promptRecordDir(planPath); diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index 3d9115f3a6e..8502d0d3b11 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -579,12 +579,24 @@ describe('composeReview — modeled-system defect-layer cap', () => { ids.map((id) => `Layer walked: ${id} — clear.`).join('\n'); // A genuine reverse-audit auditor: the identity line, a real diff read // (so `diffToolCalls > 0`), and the given receipts as its final text. - const auditor = (id: string, receipts: string) => - transcript(id, `${IDENTITY}\nread_file(file_path="${DIFF}")`, { + // A GENUINE auditor: launched with the prompt the CLI recorded for the + // role, and it opened the brief that prompt points at. A receipt only + // counts from one of these — otherwise a compliant sibling's floor could + // carry a hand-written auditor's claims. + const auditor = (id: string, receipts: string) => { + const planPath = join(dir, 'plan.json'); + const brief = briefPath(planPath, 'reverse-audit'); + const launch = + 'You are review agent `reverse-audit`.\n' + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}")`; + transcript(id, launch, { toolCalls: 1, range: [0, 100], + opens: [brief], text: receipts, }); + }; const markedPlan = (domains: string[]) => coveredPlan(['verify', 'reverse-audit'], { repositoryContext: sentinel(domains), diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index 51d4a343cda..40b6c9dd80f 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -1521,6 +1521,90 @@ describe('cost-ledger — prior-session bounds, faults and wall time', () => { ); } + it('renders the resumed-run line, singular and plural, and not otherwise', () => { + const one = renderLedger({ + totals: { + calls: 1, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 1, + thoughtsTokens: 0, + firstAt: null, + lastAt: null, + wallSeconds: 60, + }, + main: { + id: 'main', + label: 'main loop', + calls: 1, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 1, + thoughtsTokens: 0, + firstAt: null, + lastAt: null, + }, + agents: [], + priorSessions: 1, + missingStreams: 0, + }); + expect(one).toContain('resumed run: totals include 1 earlier session '); + const two = renderLedger({ + totals: { + calls: 1, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 1, + thoughtsTokens: 0, + firstAt: null, + lastAt: null, + wallSeconds: 60, + }, + main: { + id: 'main', + label: 'main loop', + calls: 1, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 1, + thoughtsTokens: 0, + firstAt: null, + lastAt: null, + }, + agents: [], + priorSessions: 2, + missingStreams: 0, + }); + expect(two).toContain('2 earlier sessions'); + const none = renderLedger({ + totals: { + calls: 1, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 1, + thoughtsTokens: 0, + firstAt: null, + lastAt: null, + wallSeconds: 60, + }, + main: { + id: 'main', + label: 'main loop', + calls: 1, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 1, + thoughtsTokens: 0, + firstAt: null, + lastAt: null, + }, + agents: [], + priorSessions: 0, + missingStreams: 0, + }); + expect(none).not.toContain('resumed run'); + }); + it("clamps a prior session's chat to the moment the next attempt began", () => { // The interrupted CLI session went on serving unrelated turns after the // review died; billing those as review cost is the mirror of the diff --git a/packages/cli/src/commands/review/cost-ledger.ts b/packages/cli/src/commands/review/cost-ledger.ts index 02a17f023b0..faef6e7bc07 100644 --- a/packages/cli/src/commands/review/cost-ledger.ts +++ b/packages/cli/src/commands/review/cost-ledger.ts @@ -44,7 +44,7 @@ import { textOf, } from './lib/transcripts.js'; import { labelFromIdentityLine } from './lib/agent-identity.js'; -import { priorSessionEntries } from './lib/run-ledger.js'; +import { currentSessionEntry, priorSessionEntries } from './lib/run-ledger.js'; interface CostLedgerArgs { plan: string; @@ -82,6 +82,11 @@ interface Ledger { * than it was. */ priorSessions: number; + /** + * Streams that exist but could not be read (a stat, read or parse failure). + * A silent skip would present a lower total as a complete one. + */ + missingStreams: number; } interface UsageEvent { @@ -333,8 +338,12 @@ export function computeLedger( planPath: string, env: NodeJS.ProcessEnv = process.env, ): Ledger { - const floorMs = planFloorMs(planPath); + const planMs = planFloorMs(planPath); const { projectDir, sessionId, dir } = transcriptPaths(env); + // A review that starts inside an EXISTING session must not bill that + // session's earlier turns; its ledger entry says when it became an attempt. + const own = currentSessionEntry(planPath, env); + const floorMs = own === null ? planMs : Math.max(planMs, own.atMs); const chatFile = join(projectDir, 'chats', `${sessionId}.jsonl`); let mainEvents: UsageEvent[]; @@ -370,10 +379,14 @@ export function computeLedger( const agents: StreamCost[] = []; const agentEvents: UsageEvent[] = []; + // Streams that exist but could not be read: a silent skip would present a + // lower total as a complete one. + let missingStreams = 0; const readAgentDir = ( agentDir: string, names: string[], ceilingMs?: number, + streamFloorMs?: number, ): number => { let streams = 0; for (const f of names) { @@ -382,17 +395,19 @@ export function computeLedger( try { mtimeMs = statSync(full).mtimeMs; } catch { + missingStreams++; continue; // Gone between listing and stat. } // The transcript dir is session-scoped and never pruned: files from // earlier reviews this session predate the floor, and a file whose last // write predates it cannot hold an above-floor record — the same // membership test `readTranscripts` applies. Skip it without opening. - if (mtimeMs < floorMs) continue; + if (mtimeMs < (streamFloorMs ?? floorMs)) continue; let read: { events: UsageEvent[]; launch: string }; try { - read = readUsage(full, floorMs, ceilingMs); + read = readUsage(full, streamFloorMs ?? floorMs, ceilingMs); } catch { + missingStreams++; continue; // This agent's record is lost; the rest still count. } if (read.events.length === 0) continue; @@ -430,7 +445,10 @@ export function computeLedger( events = readUsage( paths?.chatFile ?? join(projectDir, 'chats', `${entry.sessionId}.jsonl`), - floorMs, + // Floored at the moment THIS attempt began — the plan floor plus + // that session's own start. NOT the current attempt's floor, which is + // later and would erase the prior attempt entirely. + Math.max(planMs, entry.atMs), entry.endsAtMs ?? undefined, ).events; priorMainEvents.push(...events); @@ -449,6 +467,7 @@ export function computeLedger( // operator kept working would otherwise fold unrelated subagent // cost into this review. entry.endsAtMs ?? undefined, + Math.max(planMs, entry.atMs), ); } catch (err) { // Absent is the legitimate state (the attempt died before launching @@ -528,6 +547,7 @@ export function computeLedger( main, agents, priorSessions, + missingStreams, }; } @@ -553,6 +573,11 @@ export function renderLedger(ledger: Ledger): string { ` resumed run: totals include ${plural(ledger.priorSessions, 'earlier session')} of this review`, ); } + if (ledger.missingStreams > 0) { + lines.push( + ` ⚠️ ${plural(ledger.missingStreams, 'stream')} could not be read; this ledger is a floor`, + ); + } if (ledger.agents.length > 0) { // Equal labels fold into one row marked (×N): a relaunched agent keeps // its label, and verify shards deliberately share one — the marker reads diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index 32d0d3a977a..93164ac4325 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { layerAuditGate } from './layer-audit-gate.js'; import { appendRunSession, recordResume } from './run-ledger.js'; +import { briefPath, promptRecordDir, recordPrompt } from './prompt-record.js'; import { MODELED_SYSTEM_DOMAIN } from './audit-layers.js'; /** A valid RepositoryContext (strict schema) with the given domains. */ @@ -230,16 +231,55 @@ describe('the real reader on a resumed run — prior-session auditors count', () /** A corroborated reverse auditor in `session`: identity line, a baked * territory read it actually performed, and receipts for `covered`. */ function auditorTranscript(session: string, covered: string[]): void { + // The CLI's own record plus the brief it points at: a receipt only + // counts from an auditor that got THIS prompt and opened that brief. + const key = 'reverse-audit'; + const brief = briefPath(plan, key); const launch = 'You are review agent `reverse-audit` — hunt the gaps.\n' + + `read_file(file_path="${brief}")\n` + `read_file(file_path="${diff}", offset=0, limit=100)`; - const base = { agentId: `ra-${session}`, agentName: 'general-purpose' }; + mkdirSync(promptRecordDir(plan), { recursive: true }); + writeFileSync(brief, 'The reverse-audit brief.'); + recordPrompt(plan, key, launch); + const base = { + agentId: `ra-${session}`, + agentName: 'general-purpose', + sessionId: session, + }; const lines = [ JSON.stringify({ ...base, type: 'user', message: { role: 'user', parts: [{ text: launch }] }, }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { name: 'read_file', args: { file_path: brief } }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'brief' }, + }, + }, + ], + }, + }), JSON.stringify({ ...base, type: 'assistant', diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.ts index d7b75714a4b..550c38dce9a 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.ts @@ -57,6 +57,11 @@ import { statSync, readFileSync } from 'node:fs'; import { readRunTranscripts } from './transcripts.js'; import { bakedRanges, openedTheTerritory } from './retirement.js'; +import { + readRecordedPrompts, + wasDeliveredVerbatim, + briefPath, +} from './prompt-record.js'; import { repositoryContextOf, type RepositoryContext, @@ -103,10 +108,25 @@ function readReverseAuditReturns( const auditors = readRunTranscripts(planPath, since, env, diffPath, { currentDirOptional: true, }).filter((t) => t.launchPrompt.includes(REVERSE_AUDIT_IDENTITY)); + // A receipt only counts from an auditor that got the CLI's own prompt + // and opened the brief it points at. Territory alone let a compliant + // sibling satisfy the floor while a hand-written auditor supplied the + // receipt — the launch is exactly what the built record proves. + const built = readRecordedPrompts(planPath); + const delivered = (t: (typeof auditors)[number]): boolean => { + for (const [key, prompt] of built) { + if (prompt.trim() === '') continue; + if (!wasDeliveredVerbatim(t.launchPrompt, prompt)) continue; + const needle = JSON.stringify(briefPath(planPath, key)); + if (t.successfulCallArgs.some((a) => a.includes(needle))) return true; + } + return false; + }; const corroborated = auditors .filter( (t) => t.diffToolCalls > 0 && + delivered(t) && openedTheTerritory( t.diffReads, bakedRanges(t.launchPrompt, diffPath), diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index dde419d0cd6..b695abe3203 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -228,6 +228,26 @@ export function priorSessionIds( * exists to prevent. `null` when nothing followed it (it is the newest prior * entry and the current session's own start is not recorded here). */ +/** + * This session's own ledger entry, if it wrote one. + * + * Needed for the cost floor: a review that starts inside an EXISTING CLI + * session must not bill that session's earlier, unrelated turns, and the + * plan floor alone cannot tell them apart. No authorization gate here — a + * session reading its own entry is not reading anyone else's evidence. + */ +export function currentSessionEntry( + planPath: string, + env: NodeJS.ProcessEnv = process.env, +): { sessionId: string; atMs: number } | null { + const current = env['QWEN_CODE_SESSION_ID']?.trim().toLowerCase(); + if (!current) return null; + return ( + readSessions(planPath).find((e) => e.sessionId.toLowerCase() === current) ?? + null + ); +} + /** * Did the CURRENT session actually earn the right to read prior evidence? * diff --git a/packages/cli/src/commands/review/repo-context.ts b/packages/cli/src/commands/review/repo-context.ts index 6d8d01fc699..d085bee68ff 100644 --- a/packages/cli/src/commands/review/repo-context.ts +++ b/packages/cli/src/commands/review/repo-context.ts @@ -15,7 +15,7 @@ import { utimesSync, } from 'node:fs'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; -import { writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { git, gitOpt, gitRaw } from './lib/git.js'; import { manifestRepositoryContextProvider } from './lib/manifest-repository-context.js'; import { @@ -409,8 +409,31 @@ export function runRepoContext( // restored after it; letting it advance re-keyed the epoch mid-run and // silently orphaned everything recorded before this command ran. const planStat = statSync(planPath); - atomicWriteFileSync(planPath, stringifyPlanReport(plan)); - utimesSync(planPath, planStat.atime, planStat.mtime); + const serialized = stringifyPlanReport(plan); + // The cheapest way to keep the epoch is not to move it: an enrichment that + // changes nothing (the common case on a resumed run, where the plan + // already carries its context) skips the write entirely, so the + // commit-then-restore window cannot open at all. + let planUnchanged = false; + try { + planUnchanged = readFileSync(planPath, 'utf8') === serialized; + } catch { + planUnchanged = false; + } + if (!planUnchanged) { + atomicWriteFileSync(planPath, serialized); + utimesSync(planPath, planStat.atime, planStat.mtime); + // The rename commits a new mtime before the restore lands. Verify it: + // an unrestored epoch fences out this run's own evidence, and a silent + // one is worse than a loud one. + if (statSync(planPath).mtimeMs !== planStat.mtimeMs) { + writeStderrLine( + `WARNING: could not restore the plan's timestamp at ${planPath}; ` + + `the run epoch has moved, and evidence recorded before this ` + + `command may no longer be visible to this run.`, + ); + } + } writeStdoutLine( context === null ? `Wrote null repository context to ${outPath}` From a70ea211dd6fbbb189644ffba52813d12a7e74fd Mon Sep 17 00:00:00 2001 From: wenshao Date: Fri, 14 Aug 2026 23:10:56 +0800 Subject: [PATCH 11/21] fix(review): close the certification, epoch and supersession gaps the audit found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five behavioural defects, each mutation-proven and each now pinned by a probe that reddens when the fix is reverted. The Step 4/5 gate read run-scoped evidence WITHOUT the dead-record filter its coverage sibling applies. Delivery is checked as recorded prompt plus opened brief and never consults the return, so an interrupted attempt's verifier that opened its brief and died satisfied the floor — a verification nobody performed, certified. The filter now lives in one function both gates call: a gate reading a different evidence set than its siblings is how a review certifies work nobody did, and that is exactly what the split allowed. The plan-epoch restore passed `Date` objects to `utimesSync`. A `Date` carries whole milliseconds; APFS and ext4 keep nanoseconds. The restored mtime was therefore CLOSE to the original and not equal to it, the exact `planMtimeMs === planMtime` fence read it as a different plan, and every session entry was dropped — the resume ledger silently emptied on the filesystems this actually runs on. Restored from float seconds now, and the verification compares with a millisecond of tolerance instead of exact float equality, which was firing on every content-changing enrichment (28 leaked WARNINGs in the suite, and not one test noticed). The recovery count's supersession check iterated ALL records, so two prior records for one obligation — a whiff-relaunch inside the interrupted attempt — superseded each other and both vanished from the count while coverage still credited their chunk. Narrowed to current-session records: the count answers "what did this run reuse", so only a relaunch HERE supersedes. Two test-only defects in the same pass. The check-coverage test titled as accepting prior-session Step 4/5 evidence contained none: with no such records both steps fail as not-built and merge into one gap whose subject is the combined `'verification and reverse audit'`, which equals neither name the assertions excluded — it passed on a review where nothing was verified. It now builds real Step 4/5 fixtures and asserts no gaps at all. The current-session billing floor had no discriminating test (reverting it kept all 56 green), and the APPROVE-path continuity separator had none either (dropping the clause glued the note onto the verdict with a single space, all 230 still green). Also: three JSDoc blocks that mid-file insertions had stranded on the wrong symbols, and a paragraph promising a window fallback for entries without `planMtimeMs` that the code stopped honouring when the fallback was removed — two contradictory contracts in one file, with the doc's version being the one that loses a resume its evidence. --- .../commands/review/check-coverage.test.ts | 135 +++++++++++++++--- .../commands/review/compose-review.test.ts | 13 +- .../src/commands/review/cost-ledger.test.ts | 26 ++++ .../cli/src/commands/review/lib/coverage.ts | 79 +++++++--- .../src/commands/review/lib/prompt-record.ts | 14 +- .../cli/src/commands/review/lib/run-ledger.ts | 42 +++--- .../src/commands/review/repo-context.test.ts | 42 ++++++ .../cli/src/commands/review/repo-context.ts | 18 ++- 8 files changed, 301 insertions(+), 68 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 42e7a02a4f5..af9a5b3dbaf 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2260,32 +2260,110 @@ describe('coverage — a resumed run credits the prior attempt through the ledge }); describe('verificationGaps — a resumed run reads the prior attempt', () => { + /** Re-home a transcript into another session, re-stamping its records. */ + function moveToSession(id: string, session: string): void { + mkdirSync(join(dir, 'subagents', session), { recursive: true }); + const from = join(dir, 'subagents', 'S1', `agent-${id}.jsonl`); + writeFileSync( + join(dir, 'subagents', session, `agent-${id}.jsonl`), + readFileSync(from, 'utf8').replaceAll( + '"sessionId":"S1"', + `"sessionId":"${session}"`, + ), + ); + rmSync(from, { force: true }); + } + + /** The ledger `fetch-pr` writes, through the real writers. */ + function ledger(planPath: string, ...ids: string[]): void { + const nowMs = Date.now(); + ids.forEach((id, i) => + appendRunSession( + planPath, + { QWEN_CODE_SESSION_ID: id }, + i === ids.length - 1 ? nowMs + 1500 : nowMs, + ), + ); + recordResume(planPath, ENV, nowMs + 1500); + } + + /** + * A compliant Step 4/5 agent: recorded prompt, brief and findings on disk, + * and a transcript of an agent launched verbatim with it that opened both. + * Returns the agent id so the caller can re-home it into a prior session. + */ + function step45( + planPath: string, + key: string, + opts: { returned?: boolean } = {}, + ): string { + const d = promptRecordDir(planPath); + mkdirSync(d, { recursive: true }); + const brief = briefPath(planPath, key); + writeFileSync(brief, `The ${key} brief.`); + const findings = findingsFilePath(planPath, key); + writeFileSync(findings, '- **[Critical]** x.ts:1 — y'); + const prompt = + `You are review agent \`${key}\`.\n` + + `read_file(file_path="${findings}")\n` + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}")`; + writeFileSync(join(d, `${encodeURIComponent(key)}.txt`), prompt); + const id = `v-${key.replace(/[^a-z0-9]/gi, '_')}`; + transcript(id, prompt, { + calls: 2, + opens: [brief, findings], + // `returned: false` is the died-mid-flight shape: every delivery check + // still passes (recorded prompt, brief opened, findings read) and only + // the final text is missing, which is exactly the record that must not + // certify a verification. + ...(opts.returned === false ? { text: '' } : {}), + }); + return id; + } + it('accepts Step 4/5 evidence that exists only in a prior session', () => { // The zero-launch continuation, pinned at the verification floor rather // than inferred from its coverage sibling: a current-session-only reader // regressing here would report the steps as never run. + // + // The fixture must BUILD both steps. `plan()` alone emits neither role, + // so with no Step 4/5 records at all the two failures merge into one gap + // whose subject is the combined `'verification and reverse audit'` — + // which equals neither exact string, and an assertion pair written as + // `not.toContain('verification')` then passes on a review where nothing + // was verified. That is what this test used to do. const p = plan(); - for (const name of readdirSync(join(dir, 'subagents', 'S1'))) { - mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); - const from = join(dir, 'subagents', 'S1', name); - writeFileSync( - join(dir, 'subagents', 'S0', name), - readFileSync(from, 'utf8').replaceAll( - '"sessionId":"S1"', - '"sessionId":"S0"', - ), - ); - rmSync(from, { force: true }); - } - const now = Date.now(); - appendRunSession(p, { QWEN_CODE_SESSION_ID: 'S0' }, now); - appendRunSession(p, { QWEN_CODE_SESSION_ID: 'S1' }, now + 1500); - recordResume(p, ENV, now + 1500); + const ids = [step45(p, 'verify'), step45(p, 'reverse-audit')]; + for (const id of ids) moveToSession(id, 'S0'); + ledger(p, 'S0', 'S1'); rmSync(join(dir, 'subagents', 'S1'), { recursive: true, force: true }); const r = verificationGaps(p, { postsFindings: true }, ENV); - expect(r.gaps.map((g) => g.subject)).not.toContain('verification'); - expect(r.gaps.map((g) => g.subject)).not.toContain('reverse audit'); + // No gaps AT ALL, not the absence of two names: the combined subject is + // exactly the shape a name-based assertion cannot see. + expect(r.gaps).toEqual([]); + expect(r.ok).toBe(true); + }); + + it('refuses prior-session Step 4/5 evidence whose agent never returned', () => { + // The same fixture, minus the return: an interrupted attempt's verifier + // that opened its brief and died satisfies every delivery check — the + // prompt was recorded, the brief was read — while its verification never + // existed. The gate reads live records only, and both steps come back + // owed. + const p = plan(); + const ids = [ + step45(p, 'verify', { returned: false }), + step45(p, 'reverse-audit', { returned: false }), + ]; + for (const id of ids) moveToSession(id, 'S0'); + ledger(p, 'S0', 'S1'); + rmSync(join(dir, 'subagents', 'S1'), { recursive: true, force: true }); + + const r = verificationGaps(p, { postsFindings: true }, ENV); + expect(r.ok).toBe(false); + expect(r.gaps).not.toEqual([]); }); }); @@ -2378,6 +2456,27 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' expect(r.recoveredAgents).toBe(0); }); + it('counts two prior records that only supersede each other', () => { + // A whiff-relaunch INSIDE the interrupted attempt: two records for the + // same chunk, both clearing the bar, and no current-session agent at all. + // Checked against every record, each supersedes the other and both drop + // out — the continuity note then reports nothing while coverage credits + // the chunk, so on this single-chunk plan the recovered work appears + // nowhere. Supersession is about what THIS run re-did. + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1first', good(1), { calls: 2 }); + moveToSession('a1first', 'S0'); + transcript('a1retry', good(1), { calls: 3 }); + moveToSession('a1retry', 'S0'); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.ok).toBe(true); + expect(r.coveredChunks).toEqual([1, 2]); + expect(r.recoveredAgents).toBe(2); + }); + it('does NOT credit a prior agent that died mid-flight', () => { // Verbatim prompt, a logged diff read, and no return: the session was // killed before it reported. Crediting it would let the resumed run skip diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index 8502d0d3b11..aed22b6d962 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -5850,7 +5850,18 @@ describe('composeReview — a resumed run is continuity, not a coverage gap', () const r = composeReview(input); expect(r.event).toBe('APPROVE'); - expect(r.body).toContain('Resumed run (not a gap): 1 agent result(s)'); + // The EXACT joined body, not a substring: on the approve path the + // separator is chosen per-render, and continuity is the only block + // present here. Asserted as a whole, a separator that forgot this block + // glues the note onto the verdict sentence with a single space; asserted + // with `toContain`, that reads identically. + expect(r.body).toBe( + 'No issues found. LGTM! ✅\n\n' + + 'Resumed run (not a gap): 1 agent result(s) from the interrupted ' + + 'earlier attempt were re-certified from the harness records and ' + + 'counted as reviewed.\n\n' + + '_— test-model via Qwen Code /review (vunknown)_', + ); expect(r.body).not.toContain('Not reviewed: review continuity'); expect(r.body).not.toContain('Partially reviewed'); }); diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index 40b6c9dd80f..27d2d914614 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -1402,6 +1402,32 @@ describe('cost-ledger — a resumed run bills the whole review', () => { ); } + it('bills the current session from its own entry, not from the plan', () => { + // The floor this pins: a `/review` launched inside a long-lived CLI + // session must not bill that session's earlier turns. The plan's mtime is + // 10:00 and this session's ledger entry is 10:09, so a conversation at + // 10:05 sits between the two candidate floors — the only place the + // difference is observable, and every other fixture here puts its events + // above both. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', `${SESSION}.jsonl`), + [ + // After the plan, before this attempt began: the operator's own + // conversation, which the review did not cause. + event('2026-08-03T10:05:00Z', { input: 900_000, output: 40_000 }), + // The review itself. + event('2026-08-03T10:10:00Z', { input: 500, output: 50 }), + ].join(''), + ); + runLedger(plan, project); + + const ledger = computeLedger(plan, env); + expect(ledger.main.calls).toBe(1); + expect(ledger.main.inputTokens).toBe(500); + expect(ledger.main.outputTokens).toBe(50); + }); + it("folds the interrupted attempt's main loop and agents into the totals", () => { const { plan, env, project } = fixture(); runLedger(plan, project); diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index cd6f8492189..0bb3a519c26 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -422,16 +422,7 @@ export function coverageFromTranscripts( plan.diffPathAbsolute, { currentDirOptional: true }, ); - // A PRIOR attempt's agent that never returned did not review anything: the - // session died mid-flight, so its findings never existed. Dropped HERE, at - // the source, rather than in the coverage walk alone — the same record - // would otherwise still satisfy roster matching and the Step 4/5 delivery - // floor, and a gate reading a different evidence set than its siblings is - // how a review certifies work nobody did. An empty return in the CURRENT - // session is an agent still running, which the idle checks own. - const records = allRecords.filter( - (r) => !(r.fromPriorSession && r.finalText.trim() === ''), - ); + const records = liveRecords(allRecords); const built = readRecordedPrompts(planPath); const blindAgents: string[] = []; @@ -508,18 +499,30 @@ export function coverageFromTranscripts( // suppressed when ANOTHER record satisfies the same target — same chunk served // by a verbatim launch that opened the diff, or same built prompt delivered // verbatim to an agent that opened its brief. - const chunkSatisfied = (c: number, self: AgentRecord): boolean => { + // `only` narrows WHICH records may supersede. Left open (the default) for + // the gap and uncoverable walks, where any qualifying record is a genuine + // repair whichever attempt ran it; narrowed to the current session for the + // recovery COUNT — see `supersededByCurrent`. + const chunkSatisfied = ( + c: number, + self: AgentRecord, + only: (r: AgentRecord) => boolean = () => true, + ): boolean => { const b = builtOf(`chunk-${c}`); if (b === undefined) return false; return records.some( (r) => r !== self && + only(r) && assignedChunk(r) === c && wasDeliveredVerbatim(r.launchPrompt, b) && r.diffToolCalls > 0, ); }; - const keySatisfied = (rec: AgentRecord): boolean => { + const keySatisfied = ( + rec: AgentRecord, + only: (r: AgentRecord) => boolean = () => true, + ): boolean => { for (const key of built.keys()) { const b = builtOf(key); if (b === undefined) continue; @@ -529,6 +532,7 @@ export function coverageFromTranscripts( records.some( (r) => r !== rec && + only(r) && wasDeliveredVerbatim(r.launchPrompt, b) && r.successfulCallArgs.some((a) => a.includes(needle)), ) @@ -540,6 +544,26 @@ export function coverageFromTranscripts( }; const superseded = (rec: AgentRecord, chunk: number | null): boolean => chunk !== null ? chunkSatisfied(chunk, rec) : keySatisfied(rec); + /** + * Was this prior-session record's obligation redone in THIS session? + * + * The recovery count answers "what work did this run reuse", so only a + * current-session relaunch supersedes: two prior records that both clear the + * bar — a whiff-relaunch inside the interrupted attempt, say — otherwise + * supersede EACH OTHER and both vanish from the count, while coverage still + * credits their chunk. The continuity note would then under-report work the + * same report simultaneously counts as reviewed, which on a single-chunk + * plan means the recovered work appears nowhere at all. + */ + const supersededByCurrent = ( + rec: AgentRecord, + chunk: number | null, + ): boolean => { + const current = (r: AgentRecord): boolean => r.fromPriorSession !== true; + return chunk !== null + ? chunkSatisfied(chunk, rec, current) + : keySatisfied(rec, current); + }; // Parsed once per record: the gap scan also feeds the supersession check // below, and the parse is not free on a long return. @@ -1052,7 +1076,7 @@ export function coverageFromTranscripts( // Not if a CURRENT record already satisfied the same obligation: the // count is what the continuity note reports, and announcing recovery // for superseded work would misdescribe what this run reused. - !superseded(r, assignedChunk(r)), + !supersededByCurrent(r, assignedChunk(r)), ).length; return { @@ -1429,6 +1453,25 @@ export interface VerificationReport { * CLI recorded building (`reverse-audit` / `reverse-audit--chunk-N` / `verify`) and * the harness's transcript of an agent launched with it that opened its brief. */ +/** + * Drop a PRIOR attempt's agents that never returned. + * + * A session that died mid-flight left records whose findings never existed: + * the agent opened its brief, said nothing, and the process went away. Such a + * record still carries a recorded prompt and an opened brief, which is the + * whole of the Step 4/5 delivery floor — so left in, it certifies a + * verification nobody performed. An empty return in the CURRENT session is a + * different thing entirely: an agent still running, which the idle checks own. + * + * Every gate that reads run-scoped evidence goes through here. The filter + * lived at one read site and not the other, and the site without it was the + * one that certifies Steps 4 and 5 — a gate reading a different evidence set + * than its siblings is how a review certifies work nobody did. + */ +function liveRecords(all: AgentRecord[]): AgentRecord[] { + return all.filter((r) => !(r.fromPriorSession && r.finalText.trim() === '')); +} + export function verificationGaps( planPath: string, opts: { postsFindings: boolean }, @@ -1437,12 +1480,10 @@ export function verificationGaps( const { plan, mtimeMs } = readPlan(planPath); // Run-scoped for the same reason as `coverageFromTranscripts`: a resumed // run's Step 4/5 evidence may sit in the interrupted attempt's session dir. - const records = readRunTranscripts( - planPath, - mtimeMs, - env, - plan.diffPathAbsolute, - { currentDirOptional: true }, + const records = liveRecords( + readRunTranscripts(planPath, mtimeMs, env, plan.diffPathAbsolute, { + currentDirOptional: true, + }), ); const built = readRecordedPrompts(planPath); const gaps: VerificationReport['gaps'] = []; diff --git a/packages/cli/src/commands/review/lib/prompt-record.ts b/packages/cli/src/commands/review/lib/prompt-record.ts index 768c66928a0..dc2c13a6171 100644 --- a/packages/cli/src/commands/review/lib/prompt-record.ts +++ b/packages/cli/src/commands/review/lib/prompt-record.ts @@ -37,13 +37,6 @@ import { import { dirname, join, basename, resolve } from 'node:path'; import { writeStderrLineSafe } from '../../../utils/stdioHelpers.js'; -/** - * Where the prompts this plan's agents were built from are recorded. - * - * Derived from the plan path, by both the writer and the reader, so that neither - * takes it as an argument. A path the model can choose is a path the model can - * point somewhere flattering. - */ /** * Slack for the run-epoch fence: absorbs the sub-millisecond skew between a * file mtime (fractional) and `Date.now()` (integral) when a record is @@ -69,6 +62,13 @@ export function runEpochMs(planPath: string): number { } } +/** + * Where the prompts this plan's agents were built from are recorded. + * + * Derived from the plan path, by both the writer and the reader, so that neither + * takes it as an argument. A path the model can choose is a path the model can + * point somewhere flattering. + */ export function promptRecordDir(planPath: string): string { const p = resolve(planPath); return join(dirname(p), `${basename(p).replace(/\.json$/i, '')}-prompts`); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index b695abe3203..ed783306736 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -64,9 +64,11 @@ const FUTURE_SLACK_MS = 2000; * its late transcripts would then be credited here. An entry carries the * mtime it saw, and a reader keeps only entries that saw THIS plan — which a * fresh run necessarily rewrote and a resumed run deliberately did not. - * Entries without the field (written before it existed) fall back to the - * window, so an in-flight upgrade degrades rather than erasing a resumable - * run's ledger. + * Entries without the field never exist in the wild — it shipped in the same + * change as the ledger itself — so there is no fallback: an entry that cannot + * say which plan it saw is dropped. (An earlier revision degraded to the + * window instead; the fallback was removed as unsound and this paragraph + * outlived it by one round.) */ function planMtimeMs(planPath: string): number | null { try { @@ -84,7 +86,7 @@ function runCeilingMs(nowMs: number = Date.now()): number { interface SessionEntry { sessionId: string; atMs: number; - /** The plan mtime this entry was written against; absent on old files. */ + /** The plan mtime this entry was written against. Required on read. */ planMtimeMs?: number; } @@ -93,11 +95,6 @@ export function runSessionsPath(planPath: string): string { return join(promptRecordDir(planPath), SESSIONS_FILE); } -/** - * This run's session entries, oldest first. Unreadable or malformed → empty: - * the failure direction is "earlier evidence invisible", which coverage answers - * by requiring the work again — never the reverse. - */ /** * Read one ledger file, refusing anything that is not a regular file. * @@ -124,6 +121,11 @@ function readLedgerFile(path: string): string | null { } } +/** + * This run's session entries, oldest first. Unreadable or malformed → empty: + * the failure direction is "earlier evidence invisible", which coverage answers + * by requiring the work again — never the reverse. + */ function readSessions(planPath: string): SessionEntry[] { try { const raw = readLedgerFile(runSessionsPath(planPath)); @@ -217,17 +219,6 @@ export function priorSessionIds( return priorSessionEntries(planPath, env).map((e) => e.sessionId); } -/** - * The same prior sessions, with the timestamps that bound them. - * - * `endsAtMs` is the NEXT ledger entry's `atMs` — the moment the following - * attempt started, which is the only end boundary this run records. The cost - * ledger clamps a prior session's chat usage to it: an interrupted session - * whose CLI kept being used for unrelated turns afterwards would otherwise - * bill that activity as review cost, the mirror of the omission the ledger - * exists to prevent. `null` when nothing followed it (it is the newest prior - * entry and the current session's own start is not recorded here). - */ /** * This session's own ledger entry, if it wrote one. * @@ -270,6 +261,17 @@ function resumeAuthorized( ); } +/** + * The same prior sessions, with the timestamps that bound them. + * + * `endsAtMs` is the NEXT ledger entry's `atMs` — the moment the following + * attempt started, which is the only end boundary this run records. The cost + * ledger clamps a prior session's chat usage to it: an interrupted session + * whose CLI kept being used for unrelated turns afterwards would otherwise + * bill that activity as review cost, the mirror of the omission the ledger + * exists to prevent. `null` when nothing followed it (it is the newest prior + * entry and the current session's own start is not recorded here). + */ export function priorSessionEntries( planPath: string, env: NodeJS.ProcessEnv = process.env, diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index 4d380fbee68..d9814d30f74 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -1047,4 +1047,46 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( rmSync(root, { recursive: true, force: true }); } }); + + it('preserves a SUB-MILLISECOND plan mtime, and stays silent about it', () => { + // The case a `Date`-backdated fixture cannot reach. `Date` carries whole + // milliseconds; APFS and ext4 keep nanoseconds. Restoring from + // `planStat.mtime` truncates the remainder, so the plan comes back with a + // mtime that is CLOSE to the original and not equal to it — and the run + // ledger's exact `planMtimeMs === planMtime` fence reads that as a + // different plan and drops every session entry, silently emptying the + // resume ledger. The test that shipped alongside the restore used an + // integer-millisecond fixture, which round-trips exactly and sees none of + // this. + const root = mkdtempSync(join(tmpdir(), 'repo-context-epoch-sub-ms-')); + const err = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + try { + const worktree = join(root, 'wt'); + mkdirSync(worktree, { recursive: true }); + const planPath = planAt(root, { files: [{ path: 'src/a.ts' }] }); + // Seconds as a float: 0.1234567 s past the epoch second, which no + // `Date` can hold. + const seconds = Math.floor(Date.now() / 1000) - 3600 + 0.1234567; + utimesSync(planPath, seconds, seconds); + const mtimeBefore = statSync(planPath).mtimeMs; + // The fixture is only meaningful if this filesystem actually keeps the + // fraction; on one that does not, the integer case above already covers + // it. + const hasSubMs = mtimeBefore !== Math.floor(mtimeBefore); + + runRepoContext({ plan: planPath, worktree, out: join(root, 'ctx.json') }); + + if (hasSubMs) { + expect(statSync(planPath).mtimeMs).toBe(mtimeBefore); + } + // And no WARNING: the verification compares floats that survived a + // filesystem round trip, so an exact-equality check reports failure on + // every enrichment that changed anything. + const printed = err.mock.calls.map((c) => String(c[0])).join(''); + expect(printed).not.toContain("could not restore the plan's timestamp"); + } finally { + err.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/src/commands/review/repo-context.ts b/packages/cli/src/commands/review/repo-context.ts index d085bee68ff..5364eb67a9f 100644 --- a/packages/cli/src/commands/review/repo-context.ts +++ b/packages/cli/src/commands/review/repo-context.ts @@ -422,11 +422,23 @@ export function runRepoContext( } if (!planUnchanged) { atomicWriteFileSync(planPath, serialized); - utimesSync(planPath, planStat.atime, planStat.mtime); + // Seconds as a FLOAT, not the `Date` objects: a `Date` carries integer + // milliseconds, while APFS and ext4 keep nanoseconds — so restoring from + // `planStat.mtime` truncates the sub-millisecond remainder and lands on a + // timestamp that is close to the original but not equal to it. The exact + // `planMtimeMs === planMtime` fence in the run ledger reads that as a + // DIFFERENT plan and drops every session entry, silently emptying the + // resume ledger on a filesystem that keeps finer time than a `Date` can + // hold. Passing `mtimeMs / 1000` preserves the fraction. + utimesSync(planPath, planStat.atimeMs / 1000, planStat.mtimeMs / 1000); // The rename commits a new mtime before the restore lands. Verify it: // an unrestored epoch fences out this run's own evidence, and a silent - // one is worse than a loud one. - if (statSync(planPath).mtimeMs !== planStat.mtimeMs) { + // one is worse than a loud one. Compared with a millisecond of tolerance + // rather than exact float equality — `mtimeMs` is a float derived from a + // nanosecond counter, and the last bits do not survive every filesystem's + // round trip. A whole millisecond of drift is far below the epoch slack + // and far above the representation noise this must not report as failure. + if (Math.abs(statSync(planPath).mtimeMs - planStat.mtimeMs) > 1) { writeStderrLine( `WARNING: could not restore the plan's timestamp at ${planPath}; ` + `the run epoch has moved, and evidence recorded before this ` + From 4ae1c31181e9d46a34fe7d31c6eb4db8e3e32f45 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 01:35:14 +0800 Subject: [PATCH 12/21] fix(review): compare the plan-mtime fence within a millisecond, not exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux CI caught what macOS hid. Restoring the plan's mtime through `utimesSync` costs a unit in the last place on ext4 — 1786717283911.999 goes back as 1786717283911.998 — because `mtimeMs` is a double over a nanosecond clock and `utimesSync` takes seconds as a double. Restoring from float seconds rather than a `Date` narrowed the drift from whole milliseconds to a fraction of a microsecond, but the ledger compared EXACTLY, so the run's own plan still read as a different one and every session entry was still dropped. The resume ledger emptied itself on the filesystem the CI runs on. The fence now allows a millisecond. That is orders of magnitude above the representation noise and orders of magnitude below the thing it must still separate: a fresh capture of the same PR rewrites the plan seconds or minutes later, never inside the same millisecond. The two epoch tests compared exactly for the same reason and are now on the same tolerance — and the sub-millisecond one no longer infers the property from a timestamp at all: it writes the ledger entry `fetch-pr` would have written, runs the enrichment, and asserts the entry is still visible to the continuation. That is the consequence the timestamp was standing in for, and it cannot pass on a filesystem whose round trip loses the fraction. --- .../commands/review/lib/run-ledger.test.ts | 31 +++++++++++++++++++ .../cli/src/commands/review/lib/run-ledger.ts | 27 +++++++++++++++- .../src/commands/review/repo-context.test.ts | 31 +++++++++++++++++-- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 5ffb77c04cc..2280f28c623 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -351,6 +351,37 @@ describe('the properties the threat model rests on', () => { expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); + it('keeps an entry whose plan mtime drifted by a last-place unit', () => { + // What `repo-context`'s restore actually leaves behind: `mtimeMs` is a + // double over a nanosecond clock, and putting it back through + // `utimesSync` (seconds, also a double) returns it a fraction of a + // microsecond off. Compared exactly, the run's OWN plan reads as a + // different one and every entry is dropped — the resume ledger empties + // itself on ext4 and APFS. + appendRunSession(plan, envOf('S1')); + const raw = JSON.parse( + readFileSync(runSessionsPath(plan), 'utf8'), + ) as Array>; + raw[0]['planMtimeMs'] = (raw[0]['planMtimeMs'] as number) - 0.001; + writeFileSync(runSessionsPath(plan), JSON.stringify(raw)); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('still drops an entry written against a genuinely different plan', () => { + // The tolerance is representation noise, not a window. A fresh capture of + // the same PR rewrites the plan seconds or minutes later, never inside + // the same millisecond, so it stays outside. + appendRunSession(plan, envOf('S1')); + const raw = JSON.parse( + readFileSync(runSessionsPath(plan), 'utf8'), + ) as Array>; + raw[0]['planMtimeMs'] = (raw[0]['planMtimeMs'] as number) - 50; + writeFileSync(runSessionsPath(plan), JSON.stringify(raw)); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + it('drops an entry older than the slack window', () => { const mtimeMs = statSync(plan).mtimeMs; appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs) - 3000); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index ed783306736..757e4f1dfd5 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -56,6 +56,26 @@ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; */ const FUTURE_SLACK_MS = 2000; +/** + * How far an entry's recorded plan mtime may sit from the plan's current one + * and still count as the same plan. + * + * Not slack in the epoch-window sense — this is representation noise. A file + * mtime is nanoseconds on ext4 and APFS, `mtimeMs` is that value in a double, + * and restoring one through `utimesSync` (which takes seconds as a double) + * costs a unit in the last place: 1786717283911.999 goes back as + * 1786717283911.998. `repo-context` performs exactly that round trip on every + * enrichment that changes the plan, so an EXACT comparison here declares the + * run's own plan to be a different one and drops every session entry — + * silently emptying the resume ledger on the filesystems this runs on. + * + * A millisecond is orders of magnitude above that noise and orders of + * magnitude below the real thing this must still separate: a FRESH capture of + * the same PR rewrites the plan seconds or minutes later, never inside the + * same millisecond. + */ +const PLAN_MTIME_TOLERANCE_MS = 1; + /** * The plan mtime an entry was written against — the EXACT fresh-run boundary. * @@ -69,6 +89,10 @@ const FUTURE_SLACK_MS = 2000; * say which plan it saw is dropped. (An earlier revision degraded to the * window instead; the fallback was removed as unsound and this paragraph * outlived it by one round.) + * + * Compared within `PLAN_MTIME_TOLERANCE_MS`, not exactly: the mtime survives a + * `utimesSync` round trip on every content-changing enrichment, and that round + * trip costs a unit in the last place. See that constant. */ function planMtimeMs(planPath: string): number | null { try { @@ -155,7 +179,8 @@ function readSessions(planPath: string): SessionEntry[] { // no older files to be lenient toward. typeof (e as SessionEntry).planMtimeMs === 'number' && planMtime !== null && - (e as SessionEntry).planMtimeMs === planMtime, + Math.abs((e as SessionEntry).planMtimeMs! - planMtime) <= + PLAN_MTIME_TOLERANCE_MS, ); // Deduplicate on READ, not only on append: the file lives in a directory // the orchestrator can reach, and a hand-written duplicate would make a diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index d9814d30f74..35b9bc39369 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -27,6 +27,11 @@ import { } from './lib/repository-context.js'; import { repoContextCommand, runRepoContext } from './repo-context.js'; import { isolateHostGitConfig } from './lib/test-utils.js'; +import { + appendRunSession, + priorSessionIds, + recordResume, +} from './lib/run-ledger.js'; const tempRoots: string[] = []; @@ -1039,7 +1044,15 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( runRepoContext({ plan: planPath, worktree, out: join(root, 'ctx.json') }); - expect(statSync(planPath).mtimeMs).toBe(mtimeBefore); + // Within a millisecond, not exactly: `mtimeMs` is a double over a + // nanosecond clock and `utimesSync` takes seconds as a double, so a + // restore costs a unit in the last place on ext4 (…911.999 goes back as + // …911.998). That is the same tolerance the ledger's own plan fence + // uses, and the assertion that matters — the entries stay visible — is + // the one below. + expect(Math.abs(statSync(planPath).mtimeMs - mtimeBefore)).toBeLessThan( + 1, + ); // The rewrite itself still happened: the plan carries no context here, // but the write path ran (content is re-serialized). expect(() => JSON.parse(readFileSync(planPath, 'utf8'))).not.toThrow(); @@ -1069,6 +1082,10 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( const seconds = Math.floor(Date.now() / 1000) - 3600 + 0.1234567; utimesSync(planPath, seconds, seconds); const mtimeBefore = statSync(planPath).mtimeMs; + // The ledger `fetch-pr` writes an orchestrator turn before this command + // runs: S0 is the interrupted attempt, S1 the continuation reading it. + appendRunSession(planPath, { QWEN_CODE_SESSION_ID: 'S0' }); + recordResume(planPath, { QWEN_CODE_SESSION_ID: 'S1' }); // The fixture is only meaningful if this filesystem actually keeps the // fraction; on one that does not, the integer case above already covers // it. @@ -1077,8 +1094,18 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( runRepoContext({ plan: planPath, worktree, out: join(root, 'ctx.json') }); if (hasSubMs) { - expect(statSync(planPath).mtimeMs).toBe(mtimeBefore); + expect(Math.abs(statSync(planPath).mtimeMs - mtimeBefore)).toBeLessThan( + 1, + ); } + // The consequence that actually matters, asserted end to end rather + // than inferred from a timestamp: the session entry `fetch-pr` wrote + // before this command ran is still THIS run's. A restore that lost the + // fraction moved the plan out from under the ledger's fence and this + // comes back empty. + expect(priorSessionIds(planPath, { QWEN_CODE_SESSION_ID: 'S1' })).toEqual( + ['S0'], + ); // And no WARNING: the verification compares floats that survived a // filesystem round trip, so an exact-equality check reports failure on // every enrichment that changed anything. From b39a12d4e685dea35db8de2343c1fb575d6c5627 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 08:59:18 +0800 Subject: [PATCH 13/21] feat(review): expose the ledger's session count without the evidence gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resume cap reads two counters so that deleting one cannot reset it. The second counter never worked: it came from `priorSessionIds`, which is gated on the calling session already appearing in the resume marker, and that entry is written only after a ruling passes — so at ruling time the ledger term was structurally zero, and deleting `resume.json` reset the cap the ledger was supposed to backstop. The gate protects EVIDENCE — it stops a session that was never granted a resume from reading another attempt's transcripts. A count is not evidence: it says how many times this review has been picked up and nothing about what any attempt did. So the count gets its own ungated accessor, running through the same `readSessions` fences as everything else, and the cap can be wired to it. --- .../commands/review/lib/run-ledger.test.ts | 27 +++++++++++++++++++ .../cli/src/commands/review/lib/run-ledger.ts | 20 ++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 2280f28c623..a9758f6a47c 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -30,6 +30,7 @@ import { appendRunSession, priorSessionEntries, priorSessionIds, + sessionEntryCount, runSessionsPath, readResumeMarker, recordResume, @@ -63,6 +64,32 @@ beforeEach(() => { }); afterEach(() => rmSync(root, { recursive: true, force: true })); +describe('sessionEntryCount — the cap term the gate must not swallow', () => { + it('counts every ledgered session without any authorization', () => { + // The gate on `priorSessionEntries` protects evidence, and a session is + // recorded as an authorized resume only after its ruling passes — so a + // cap that read its ledger term through the gate always saw zero, and + // deleting `resume.json` reset the very cap the ledger backstops. + appendRunSession(plan, envOf('S1')); + appendRunSession(plan, envOf('S2')); + // No `authorize()` call anywhere: that is the point. + expect(sessionEntryCount(plan)).toBe(2); + }); + + it('is zero when there is no ledger at all', () => { + expect(sessionEntryCount(plan)).toBe(0); + }); + + it('applies the same fences as every other read', () => { + // A count that admitted a foreign or stale entry would cap the wrong + // number: it runs through `readSessions`, so the epoch, plan-mtime and + // charset fences all still hold. + appendRunSession(plan, envOf('S1')); + appendRunSession(plan, envOf('../evil')); + expect(sessionEntryCount(plan)).toBe(1); + }); +}); + describe('appendRunSession / priorSessionIds', () => { it('records a session and surfaces it to a LATER session as prior', () => { appendRunSession(plan, envOf('S1')); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 757e4f1dfd5..3ade215e06e 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -232,6 +232,26 @@ export function appendRunSession( } } +/** + * How many sessions this run's ledger records — a COUNT, ungated. + * + * The authorization gate on `priorSessionEntries` protects EVIDENCE: it stops + * a session that was never granted a resume from reading another attempt's + * transcripts. A count is not evidence. It says how many times this review has + * been picked up, which is exactly what a cap needs and reveals nothing about + * what any attempt did. + * + * The distinction matters because the cap read both terms through the gate, + * and the gate cannot be satisfied at ruling time: a session is recorded as an + * authorized resume only AFTER its ruling passes, so the ledger term was + * structurally zero for every ruling. Deleting `resume.json` then reset the + * cap that the ledger was supposed to backstop — the one attack the two-counter + * design existed to defeat. + */ +export function sessionEntryCount(planPath: string): number { + return readSessions(planPath).length; +} + /** * Session ids of EARLIER attempts of this same run — the current session * excluded, order preserved, deduplicated by the ledger's own append guard. From 0e41bfebcdcfc0151b548f79c83971139ef9e525 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 12:35:26 +0800 Subject: [PATCH 14/21] fix(review): verify against the CURRENT findings digest, and close the owed pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1-5, deferred two rounds ago and now closed: `verificationGaps` took the best delivery across every `verify--` key ever recorded, so a verifier that succeeded against an EARLIER findings list satisfied the floor for a list it never opened. The behaviour predates the resume work, but widening the record set to prior sessions is what made it reachable in practice, so it lands here rather than as the follow-up it was parked as. The keys are narrowed to the newest digest before ranking, dated by the digest's own findings file — shard keys of one digest land within a moment of each other, a previous list's are a round older — and keys with no findings file stay in, because they cannot be dated and also cannot reach `ok`, so they only ever make the verdict stricter. Two invariants acknowledged in earlier threads now have their probes. The empty-current-chat refusal deliberately precedes the prior-session fold — prior events must not vouch for a broken current recorder — and every refusal test predated the ledger, so the faithful mutation (prior sessions excuse the emptiness) shipped green; it now reddens a probe with a healthy prior chat and an empty current one. The rendered "totals include N earlier session(s)" line is asserted on the rendered text, where it can be deleted or crash at print time with every return-value test still green. And the fixture drift named in review is repaired at both call sites: the `runLedger` helper with a dead first parameter, and the layer-audit fixture hardcoding the derived `plan-prompts` name instead of asking `promptRecordDir`. --- .../commands/review/check-coverage.test.ts | 24 +++++++++ .../src/commands/review/cost-ledger.test.ts | 44 +++++++++++++-- .../cli/src/commands/review/lib/coverage.ts | 54 ++++++++++++++++++- .../review/lib/layer-audit-gate.test.ts | 2 +- 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index af9a5b3dbaf..28c1d33828c 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -1813,6 +1813,30 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', () expect(r.gaps).toEqual([]); }); + it('does not let an OLDER findings digest vouch for the current one', () => { + // `verify--` keys accumulate: a run that finds new Criticals + // writes a new digest's records beside the old. Taking the best delivery + // across all of them let a verifier that succeeded against an EARLIER + // list satisfy the floor for a list it never opened — and widening the + // record set to prior sessions is what made that reachable. + const p = plan(); + step45(p, 'reverse-audit'); + step45(p, 'verify--old11111111', { findings: true }); + // The current digest: built and launched, but its findings list unread. + step45(p, 'verify--new22222222', { + findings: true, + opensFindings: false, + }); + // Date the two lists apart — the round builder writes a digest's records + // in one pass, so a previous list is a round older. + const old = new Date(Date.now() - 600_000); + utimesSync(findingsFilePath(p, 'verify--old11111111'), old, old); + + const r = verificationGaps(p, { postsFindings: true }, ENV); + expect(r.ok).toBe(false); + expect(r.unverifiedFindings).toBe(true); + }); + it('passes when both verify and reverse audit ran on a review with findings', () => { const p = plan(); step45(p, 'reverse-audit'); diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index 27d2d914614..ea636cf9bc2 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -1383,8 +1383,7 @@ describe('cost-ledger — a resumed run bills the whole review', () => { } /** The ledger `fetch-pr` writes, naming the interrupted attempt S0. */ - function runLedger(plan: string, project: string): void { - void project; + function runLedger(plan: string): void { appendRunSession( plan, { QWEN_CODE_SESSION_ID: 'S0' }, @@ -1420,7 +1419,7 @@ describe('cost-ledger — a resumed run bills the whole review', () => { event('2026-08-03T10:10:00Z', { input: 500, output: 50 }), ].join(''), ); - runLedger(plan, project); + runLedger(plan); const ledger = computeLedger(plan, env); expect(ledger.main.calls).toBe(1); @@ -1428,9 +1427,44 @@ describe('cost-ledger — a resumed run bills the whole review', () => { expect(ledger.main.outputTokens).toBe(50); }); + it('still refuses an empty CURRENT chat when prior sessions have events', () => { + // The invariant the emptiness check exists for: prior events must not + // vouch for a broken current chat. The refusal tests predate the ledger + // and set up no prior session, so a refactor moving the check after the + // fold — or testing the folded set — would ship green while a resumed run + // whose new session's recorder degraded rendered a ledger that looks + // complete. + const { plan, project, env } = fixture(); + writeFileSync(join(project, 'chats', `${SESSION}.jsonl`), ''); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:05:00Z', { input: 1000, output: 100 }), + ); + runLedger(plan); + + expect(() => computeLedger(plan, env)).toThrow( + /no main-loop usage records at or after the plan/, + ); + }); + + it('announces the span in the rendered summary, not only in the object', () => { + // The only user-visible statement that the totals cover more than this + // session. Asserted on the rendered text because that is where it can be + // deleted or crash at print time with every return-value test still green. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:05:00Z', { input: 1000, output: 100 }), + ); + runLedger(plan); + + const text = renderLedger(computeLedger(plan, env)); + expect(text).toContain('1 earlier session'); + }); + it("folds the interrupted attempt's main loop and agents into the totals", () => { const { plan, env, project } = fixture(); - runLedger(plan, project); + runLedger(plan); writeFileSync( join(project, 'chats', 'S0.jsonl'), event('2026-08-03T10:01:00Z', { input: 1_000, output: 100 }), @@ -1467,7 +1501,7 @@ describe('cost-ledger — a resumed run bills the whole review', () => { it('counts a prior session with agents but a lost chat file', () => { const { plan, env, project } = fixture(); - runLedger(plan, project); + runLedger(plan); mkdirSync(join(project, 'subagents', 'S0'), { recursive: true }); writeFileSync( join(project, 'subagents', 'S0', 'agent-a0.jsonl'), diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 0bb3a519c26..74cec7688ff 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -64,6 +64,7 @@ import { wasDeliveredVerbatim, briefPath, findingsPointerOf, + findingsFilePath, } from './prompt-record.js'; import { requiredAgents, @@ -265,6 +266,16 @@ function readPlan(path: string): { plan: Plan; mtimeMs: number } { return { plan, mtimeMs: statSync(path).mtimeMs }; } +/** + * How far apart the shard keys of ONE findings digest may be written. + * + * The round builder writes a digest's records in one pass, so they land within + * milliseconds; a previous list's records are a round apart at minimum. Wide + * enough to keep a slow write together, far narrower than the gap it must + * separate. + */ +const DIGEST_WINDOW_MS = 5000; + /** `chunk 13 of 25` — written into the prompt by `agent-prompt`, in code. */ export const CHUNK_RE = /\bchunk\s+(\d+)\s+of\s+\d+\b/i; @@ -1557,6 +1568,47 @@ export function verificationGaps( return 'not-launched'; }; + /** + * Narrow a step's keys to the CURRENT findings digest. + * + * `verify--` is one key per shard per digest, and the records + * accumulate: a run that finds new Criticals writes a new digest's keys + * beside the old ones. Taking the best delivery across ALL of them let a + * verifier that succeeded against an EARLIER findings list satisfy the floor + * for a list it never opened — and widening the record set to prior sessions + * is what made that reachable in practice. + * + * The digest's own findings file dates it. Keys written together (the shards + * of one digest) land within the same moment, so the newest file plus a + * small window is the current set; anything older is a previous list's + * verification and does not vouch for this one. Keys with no findings file + * on disk stay in: they cannot be dated, and they also cannot reach `ok` — + * `deliveryOf` requires the findings read — so they can only make the + * verdict stricter. + */ + const currentDigestKeys = (planPath: string, keys: string[]): string[] => { + const dated: Array<{ key: string; mtimeMs: number }> = []; + const undatable: string[] = []; + for (const key of keys) { + try { + dated.push({ + key, + mtimeMs: statSync(findingsFilePath(planPath, key)).mtimeMs, + }); + } catch { + undatable.push(key); + } + } + if (dated.length === 0) return keys; + const newest = Math.max(...dated.map((d) => d.mtimeMs)); + return [ + ...dated + .filter((d) => d.mtimeMs >= newest - DIGEST_WINDOW_MS) + .map((d) => d.key), + ...undatable, + ]; + }; + /** The best shape across a step's keys — the floor is one agent, not all of them. */ const bestDelivery = (keys: string[]): Delivery => { if (keys.length === 0) return 'not-built'; @@ -1639,7 +1691,7 @@ export function verificationGaps( const verifyKeys = [...built.keys()].filter( (k) => k === 'verify' || k.startsWith('verify--'), ); - verify = bestDelivery(verifyKeys); + verify = bestDelivery(currentDigestKeys(planPath, verifyKeys)); if (verify !== 'ok') { unverifiedFindings = true; remediation.push( diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index 93164ac4325..317c1e9f612 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -203,7 +203,7 @@ describe('the real reader on a resumed run — prior-session auditors count', () utimesSync(plan, old, old); mkdirSync(join(dir, 'subagents', 'S1'), { recursive: true }); mkdirSync(join(dir, 'subagents', 'S0'), { recursive: true }); - mkdirSync(join(dir, 'plan-prompts'), { recursive: true }); + mkdirSync(promptRecordDir(join(dir, 'plan.json')), { recursive: true }); }); afterEach(() => rmSync(dir, { recursive: true, force: true })); From 259758bec3afe977eff38248b7c93ca1ec52507e Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 14:02:43 +0800 Subject: [PATCH 15/21] fix(review): close the round-4 blockers a paginated sweep surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine Criticals, missed for two rounds because the triage sweep read the first GraphQL page of review threads and this PR crossed one hundred — the finding list below is what a paginated read surfaced. A RETURN is now a fact, not an inference from non-empty text. `finalText` keeps the last non-empty assistant message, which includes progress narrated between tool calls — an agent that said "reading the diff now…" and died carried plausible text that certified coverage and the Step 4/5 floor. `parseTranscript` marks text with tool traffic after it as progress, and every certification consumer (liveRecords, certifies, all four supersession predicates) requires the record to have returned. That last group also closes the probe-proven fail-open where an unreturned verbatim relaunch suppressed an honest `Uncoverable:` declaration and earned the chunk off the told-range presumption, let two honest declarations annihilate into `missingChunks`, and silenced a prior attempt's `Budget gap:` disclosure as a "genuine repair". The layer-audit gate no longer treats NAMING the brief as reading it — a grep whose args contain the path cleared `delivered()` while the auditor never opened its instructions — and its record read now takes the run-epoch fence `readRecordedPrompts` documents as mandatory for history readers: without it, a dead attempt's records beside the stable plan path let a hand-launched stale prompt corroborate a run whose builder never emitted an auditor, a fail-open on the gate's own withhold-only invariant. Retirement receipts classify only from auditors that READ the cumulative findings list their prompt points at: the comparison against known findings is the audit's method, and two skipping receipts retired a chunk on a comparison nobody made. Transcript ownership is now checked for the current session, not only prior ones — a foreign-stamped file planted under the current directory was trusted as current evidence, `since` blind to it (a copy gets a fresh mtime) and the prompt pairing deterministic. And the session-directory lookup applies the harness's own filename sanitizer: the harness writes `subagents/` while the ledger's charset admits dots, so a dotted id read a path that does not exist and every reader silently saw nothing. `appendRunSession` refuses to write an entry when the plan cannot be stat'ed — `readSessions` hard-requires the field, so the entry was a guaranteed-dead write that silently lost the id on the next append's rewrite. `repo-context` captures the plan's identity before the providers run and refuses to write if it moved — a concurrent capture otherwise had this run's stale contents restored under the other run's epoch, whose ledger then passed an exact fence against a plan it never described. Every fix is pinned, and each pin was mutation-verified against the exact regression the finding names. --- .../commands/review/check-coverage.test.ts | 45 +++++++ .../cli/src/commands/review/lib/coverage.ts | 29 ++++- .../review/lib/layer-audit-gate.test.ts | 45 +++++++ .../commands/review/lib/layer-audit-gate.ts | 19 ++- .../commands/review/lib/retirement.test.ts | 120 ++++++++++++++++++ .../cli/src/commands/review/lib/retirement.ts | 23 +++- .../cli/src/commands/review/lib/run-ledger.ts | 13 +- .../commands/review/lib/transcripts.test.ts | 46 +++++++ .../src/commands/review/lib/transcripts.ts | 55 +++++++- .../src/commands/review/repo-context.test.ts | 31 +++++ .../cli/src/commands/review/repo-context.ts | 22 ++++ packages/core/src/agents/index.ts | 1 + 12 files changed, 433 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 28c1d33828c..571b26f39da 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2480,6 +2480,51 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' expect(r.recoveredAgents).toBe(0); }); + it('does NOT credit a prior agent whose text is progress, not a return', () => { + // `finalText` keeps the last non-empty assistant text, and agents narrate + // between tool calls — so an agent that said "reading the diff now" and + // died mid-flight carries plausible text. Tool traffic AFTER the text is + // what marks it as progress, and the empty-return filter alone cannot + // see it. + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1prog', good(1), { calls: 2, text: 'Reading the diff now…' }); + // Re-order: append one more tool call AFTER the text, the died-mid-work + // shape. + const f = join(dir, 'subagents', 'S1', 'agent-a1prog.jsonl'); + const lines = readFileSync(f, 'utf8').trim().split('\n'); + const textLine = lines.findIndex((l) => l.includes('Reading the diff')); + const callLine = lines.findIndex((l) => l.includes('functionCall')); + lines.push(lines[callLine], lines[callLine + 1]); + void textLine; + writeFileSync(f, lines.join('\n') + '\n'); + moveToSession('a1prog', 'S0'); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.coveredChunks).not.toContain(1); + expect(r.recoveredAgents).toBe(0); + }); + + it('an honest Uncoverable declaration survives an unreturned relaunch', () => { + // The probe from review: agent A declares chunk 1 unreachable; a verbatim + // relaunch B reads the diff once and dies. B must not supersede A — the + // declaration is the only honest account of the chunk, and B's told-range + // presumption would otherwise mark it covered. + const p = plan(); + transcript('aDecl', good(1), { + calls: 2, + text: 'Uncoverable: chunk 1 — a line exceeds the read limit', + }); + transcript('aRelaunch', good(1), { calls: 1, text: '' }); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.ok).toBe(false); + expect(r.uncoverableChunks).toEqual([1]); + expect(r.coveredChunks).not.toContain(1); + }); + it('counts two prior records that only supersede each other', () => { // A whiff-relaunch INSIDE the interrupted attempt: two records for the // same chunk, both clearing the bar, and no current-session agent at all. diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 74cec7688ff..fb1a255855e 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -525,6 +525,16 @@ export function coverageFromTranscripts( (r) => r !== self && only(r) && + // A superseding record must have RETURNED. Current-session records + // with empty finalText stay in `records` for the idle checks, and + // without this a verbatim relaunch that read the diff once and died + // mid-flight (a) suppressed an honest `Uncoverable:` declaration and + // earned the chunk off the told-range presumption, (b) let two + // honest declarations of one chunk annihilate into `missingChunks`, + // and (c) silenced a prior attempt's `Budget gap:` disclosure as a + // "genuine repair" — three symptoms of the one missing requirement + // `certifies()` and `liveRecords()` already impose. + r.returned && assignedChunk(r) === c && wasDeliveredVerbatim(r.launchPrompt, b) && r.diffToolCalls > 0, @@ -544,6 +554,8 @@ export function coverageFromTranscripts( (r) => r !== rec && only(r) && + // Same return requirement as the chunk branch above. + r.returned && wasDeliveredVerbatim(r.launchPrompt, b) && r.successfulCallArgs.some((a) => a.includes(needle)), ) @@ -597,6 +609,10 @@ export function coverageFromTranscripts( return records.some( (r) => r !== rec && + // Returned, like every superseding record: an empty return has no + // gaps BECAUSE it has nothing at all, and reading that as a + // gap-free repair silences the disclosure it never addressed. + r.returned && assignedChunk(r) === chunk && wasDeliveredVerbatim(r.launchPrompt, b) && r.diffToolCalls > 0 && @@ -614,6 +630,7 @@ export function coverageFromTranscripts( records.some( (r) => r !== rec && + r.returned && wasDeliveredVerbatim(r.launchPrompt, b) && r.successfulCallArgs.some((a) => a.includes(needle)) && gapsOf(r).length === 0, @@ -1048,8 +1065,9 @@ export function coverageFromTranscripts( // the diff actually opened earns a count here. const certifies = (r: AgentRecord): boolean => { // Same bar as the coverage walk: a prior agent that never returned did - // not finish, so it is not recovered work either. - if (r.finalText.trim() === '') return false; + // not finish, so it is not recovered work either — and "returned" means + // terminal text, not progress narrated between tool calls. + if (!r.returned) return false; // A record whose own return declares a chunk unreachable did not review // it; counting it as recovered would have the body announce work // "counted as reviewed" beside the gap that same record disclosed. @@ -1480,7 +1498,12 @@ export interface VerificationReport { * than its siblings is how a review certifies work nobody did. */ function liveRecords(all: AgentRecord[]): AgentRecord[] { - return all.filter((r) => !(r.fromPriorSession && r.finalText.trim() === '')); + // `returned`, not merely non-empty: `finalText` keeps the last non-empty + // assistant text, which includes progress narrated between tool calls — an + // agent that opened its inputs, said "reading the diff now…" and died + // carries plausible text that certifies nothing. A record with tool + // traffic after its text never returned. + return all.filter((r) => !(r.fromPriorSession && !r.returned)); } export function verificationGaps( diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index 317c1e9f612..88c1054d5ba 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -7,6 +7,7 @@ import { mkdtempSync, mkdirSync, + readFileSync, rmSync, utimesSync, writeFileSync, @@ -357,6 +358,50 @@ describe('the real reader on a resumed run — prior-session auditors count', () expect(out.some((e) => e.includes('lexing'))).toBe(false); }); + it('refuses a receipt whose auditor only NAMED the brief, never read it', () => { + // `successfulCallArgs` covers every successful tool, so a grep whose args + // merely contain the brief path cleared `delivered()` — an auditor that + // never opened its instructions supplied the receipt. Only a successful + // `read_file` of the exact brief is opening it. + ledger('S1'); + auditorTranscript('S1', LAYERS); + const f = join(dir, 'subagents', 'S1', 'agent-ra-S1.jsonl'); + // Turn the brief READ into a search that names the same path. + writeFileSync( + f, + readFileSync(f, 'utf8').replace( + '"functionCall":{"name":"read_file","args":{"file_path":' + + JSON.stringify(briefPath(plan, 'reverse-audit')) + + '}}', + '"functionCall":{"name":"search_file_content","args":{"pattern":"x","path":' + + JSON.stringify(briefPath(plan, 'reverse-audit')) + + '}}', + ), + ); + const out = layerAuditGate(plan, ENV()).unreviewed; + expect(out).toHaveLength(6); + }); + + it('refuses a STALE record a dead attempt left beside the plan', () => { + // The records read as HISTORY must take the run-epoch fence, like the + // sibling history reader in retirement: nothing clears the record dir, + // and an orchestrator that hand-launches a stale record's prompt + // verbatim otherwise corroborates a run whose builder never emitted an + // auditor — a fail-open on the gate's own withhold-only invariant. + ledger('S1'); + auditorTranscript('S1', LAYERS); + // Backdate the record to before the plan's epoch (the suite pins the + // plan at 2020-01-01, so the dead attempt's leftovers predate it). + const past = new Date(2019, 0, 1); + const rec = join( + promptRecordDir(plan), + `${encodeURIComponent('reverse-audit')}.txt`, + ); + utimesSync(rec, past, past); + const out = layerAuditGate(plan, ENV()).unreviewed; + expect(out).toHaveLength(6); + }); + it('sees nothing from a prior session the ledger never recorded', () => { // A PARTIAL walk is the discriminating shape: were the un-ledgered // transcript visible, one identity-matched auditor covering two layers diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.ts index 550c38dce9a..1fe2d4143ac 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.ts @@ -61,6 +61,7 @@ import { readRecordedPrompts, wasDeliveredVerbatim, briefPath, + runEpochMs, } from './prompt-record.js'; import { repositoryContextOf, @@ -112,13 +113,27 @@ function readReverseAuditReturns( // and opened the brief it points at. Territory alone let a compliant // sibling satisfy the floor while a hand-written auditor supplied the // receipt — the launch is exactly what the built record proves. - const built = readRecordedPrompts(planPath); + // Fenced: this reads the records as HISTORY, pairing them against the + // run's transcripts, and `readRecordedPrompts` documents the fence as + // mandatory for exactly that shape — without it a dead attempt's records + // survive beside the stable plan path, and an orchestrator that hand + // launches a stale record's prompt verbatim gets `corroborated` + // non-empty on a run whose builder never emitted an auditor. The failure + // direction of a dropped corroboration is withhold, never release. + const built = readRecordedPrompts(planPath, runEpochMs(planPath)); const delivered = (t: (typeof auditors)[number]): boolean => { for (const [key, prompt] of built) { if (prompt.trim() === '') continue; if (!wasDeliveredVerbatim(t.launchPrompt, prompt)) continue; const needle = JSON.stringify(briefPath(planPath, key)); - if (t.successfulCallArgs.some((a) => a.includes(needle))) return true; + // READ, not named: `successfulCallArgs` covers every successful + // tool, so a grep or listing whose args merely CONTAIN the brief + // path cleared this — an auditor that never opened its instructions + // supplied a receipt. Only a successful `read_file` of the exact + // brief is opening it. + if (t.successfulReadFileArgs.some((a) => a.includes(needle))) { + return true; + } } return false; }; diff --git a/packages/cli/src/commands/review/lib/retirement.test.ts b/packages/cli/src/commands/review/lib/retirement.test.ts index 79c14c77916..4efaa92e40e 100644 --- a/packages/cli/src/commands/review/lib/retirement.test.ts +++ b/packages/cli/src/commands/review/lib/retirement.test.ts @@ -161,6 +161,48 @@ describe('scheduleReverseAuditRound — the scheduler on its own', () => { }), ); } + // A compliant auditor reads the cumulative findings list its prompt + // points at — the comparison against known findings IS the audit's + // method, and the scheduler now refuses receipts from an auditor that + // skipped it. Modeled by default, like the brief-opens elsewhere; a test + // that wants a skipping auditor writes its own transcript. + const pointer = /read_file\(file_path="([^"]*\.findings\.md)"\)/.exec( + launchPrompt, + ); + if (pointer) { + lines.push( + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: pointer[1] }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'the cumulative list' }, + }, + }, + ], + }, + }), + ); + } lines.push( JSON.stringify({ ...base, @@ -1485,6 +1527,84 @@ describe('scheduleReverseAuditRound — the scheduler on its own', () => { ]); }); + it('an auditor that SKIPPED the findings read cannot retire the chunk', () => { + // The comparison against known findings IS the audit's method, and the + // brief instructs the read. Two dry receipts from auditors that skipped + // it would retire the chunk on a comparison nobody made. The fixture + // builder models the compliant read automatically, so this one writes + // its transcripts by hand, minus the read. + for (const r of [1, 2]) { + const findingsFile = writeFindingsFile( + plan, + `reverse-audit--round-${r}--skip99`, + '- **File:** src/pay.ts:42 — the double charge\n' + + '- **Severity:** Suggestion\n', + ); + const built = record( + r, + 13, + `chunk 13 round ${r} territory\n` + + `read_file(file_path="${findingsFile}")`, + ); + const id = `aud-skip-${r}`; + const base = { + agentId: id, + agentName: 'general-purpose', + sessionId: 'S1', + }; + writeFileSync( + join(dir, 'subagents', 'S1', `agent-${id}.jsonl`), + [ + JSON.stringify({ + ...base, + type: 'user', + message: { role: 'user', parts: [{ text: built }] }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: diff, offset: 0, limit: 100 }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'diff bytes' }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { role: 'model', parts: [{ text: DRY }] }, + }), + ].join('\n') + '\n', + ); + } + + const r3 = schedule(3, [13]); + // The receipts do not classify, both rounds read `unknown`, and the + // chunk stays hot. + expect(r3.due).toEqual([13]); + }); + it('quoting a WHOLE entry from the findings FILE is not a yield (post-#8597 shape)', () => { // Since #8597 the cumulative list rides a digest-named `.findings.md` // file the launch prompt points at, not the prompt itself. The echo diff --git a/packages/cli/src/commands/review/lib/retirement.ts b/packages/cli/src/commands/review/lib/retirement.ts index 892ef100eac..f188d214b6d 100644 --- a/packages/cli/src/commands/review/lib/retirement.ts +++ b/packages/cli/src/commands/review/lib/retirement.ts @@ -413,6 +413,17 @@ function substantiveClause(clause: string): boolean { * this module lands on the audit side. `memo` keys on the pointer so the * pairing walk reads each round's list once, not once per record. */ +/** + * Did this transcript's agent successfully `read_file` the findings pointer + * its record's prompt names? True when the prompt names none. + */ +function readTheFindingsPointer(rec: AgentRecord, lines: string[]): boolean { + const pointer = findingsPointerOf(lines.join('\n')); + if (pointer === null) return true; + const needle = JSON.stringify(pointer); + return rec.successfulReadFileArgs.some((a) => a.includes(needle)); +} + function findingsListFor( prompt: string, recordDir: string, @@ -755,7 +766,17 @@ export function scheduleReverseAuditRound( const failuresByRecord: CertificationFailure[][] = []; matchesByRecord.forEach((matches, i) => { const unique = matches.filter((t) => recordsPerTranscript.get(t) === 1); - const classifications = unique.map((t) => + // The auditor must have READ the cumulative findings list its prompt + // points at before its receipt can classify at all. The brief's whole + // method is the comparison against known findings; an auditor that + // skipped the read cannot have performed it, and two such receipts + // would retire the chunk on a comparison nobody made. A prompt with no + // pointer (the pre-#8597 shape, list folded in verbatim) has nothing + // to open and keeps the old bar. + const readCompliant = unique.filter((t) => + readTheFindingsPointer(t, records[i].lines), + ); + const classifications = readCompliant.map((t) => classifyReturn(t, records[i].territory, records[i].findings), ); classificationsByRecord.push(classifications); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 3ade215e06e..0019b178cef 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -217,11 +217,14 @@ export function appendRunSession( const entries = readSessions(planPath); if (entries.some((e) => e.sessionId === id)) return; const mtime = planMtimeMs(planPath); - entries.push({ - sessionId: id, - atMs: nowMs, - ...(mtime === null ? {} : { planMtimeMs: mtime }), - }); + // No plan mtime, no entry. `readSessions` hard-requires the field — an + // entry that cannot say which plan it saw is dropped on every read, and + // the next append rewrites the file from the filtered list, so a + // field-less entry is not a degraded record but a GUARANTEED-dead write + // that silently loses the id. Refusing up front is honest and identical + // in effect, minus the false success. + if (mtime === null) return; + entries.push({ sessionId: id, atMs: nowMs, planMtimeMs: mtime }); const dir = promptRecordDir(planPath); mkdirSync(dir, { recursive: true }); atomicWriteFileSync(runSessionsPath(planPath), JSON.stringify(entries), { diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index f55d26aab4f..1ab8641d2c6 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -247,6 +247,7 @@ describe('wasGivenTheDiff', () => { successfulCallArgs: [], successfulReadFileArgs: [], finalText: '', + returned: false, mtimeMs: 0, }); @@ -354,6 +355,51 @@ describe('readRunTranscripts — the run across its sessions', () => { expect(recs.map((r) => r.agentId)).toEqual(['a1']); }); + it('refuses a foreign-stamped transcript in the CURRENT directory too', () => { + // The one-sided version of the copy rule was the gap: prior directories + // checked ownership, the current one did not, so an S0-stamped file + // planted under S1 was trusted as current evidence — `since` cannot + // catch a copy (fresh mtime), and the prompt pairing passes (prompts are + // deterministic per plan). + // The fixture's records carry no session stamp (older harness shape), so + // stamp this one explicitly: the check refuses a MISMATCH, not absence. + file( + 'agent-planted.jsonl', + JSON.stringify({ + agentId: 'planted', + agentName: 'general-purpose', + sessionId: 'S0', + type: 'user', + message: { role: 'user', parts: [{ text: 'launch planted' }] }, + }) + '\n', + ); + file('agent-own.jsonl', transcript('own')); + const recs = readTranscripts(undefined, ENV); + expect(recs.map((r) => r.agentId)).toEqual(['own']); + }); + + it('looks up the session directory under the SANITIZED id', () => { + // The harness writes `subagents/` — everything outside + // [A-Za-z0-9_-] maps to '_' — while the ledger's charset admits dots + // ("room for prefixed variants"). Joined raw, an id like `resume.1` + // reaches a path that does not exist and every reader silently sees + // nothing, one underscore away from the records. + const env = { QWEN_CODE_PROJECT_DIR: dir, QWEN_CODE_SESSION_ID: 'S.dot' }; + mkdirSync(join(dir, 'subagents', 'S_dot'), { recursive: true }); + writeFileSync( + join(dir, 'subagents', 'S_dot', 'agent-a9.jsonl'), + JSON.stringify({ + agentId: 'a9', + agentName: 'general-purpose', + sessionId: 'S.dot', + type: 'user', + message: { role: 'user', parts: [{ text: 'launch a9' }] }, + }) + '\n', + ); + const recs = readTranscripts(undefined, env); + expect(recs.map((r) => r.agentId)).toEqual(['a9']); + }); + it('lists each prior session once, and only those that exist', () => { const plan = planWithLedger('S0', 'S0', 'S1'); // A prior session that exists on disk: the accessor skips a ledgered id diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index d88b2e76e51..80b7cd36e63 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -38,7 +38,10 @@ // come from the environment the CLI itself exported. import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { ToolNames } from '@qwen-code/qwen-code-core'; +import { + ToolNames, + sanitizeFilenameComponent, +} from '@qwen-code/qwen-code-core'; import { join } from 'node:path'; import { priorSessionEntries } from './run-ledger.js'; @@ -98,6 +101,17 @@ export interface AgentRecord { recordedSession: string; /** The agent's own final text, as the harness saw it. */ finalText: string; + /** + * True when `finalText` is a RETURN rather than progress: no tool activity + * follows it in the transcript. `parseTranscript` keeps the last non-empty + * assistant text, which includes narration emitted between tool calls — so + * an agent that opened its inputs, said "reading the diff now…" and died + * (or is still running) carries non-empty finalText that certifies + * nothing. The harness appends records in order and writes the final + * message last, so text with tool traffic after it is progress by + * construction. + */ + returned: boolean; /** When the transcript was last written. */ mtimeMs: number; /** @@ -146,7 +160,12 @@ export function transcriptPaths(env: NodeJS.ProcessEnv = process.env): { return { projectDir, sessionId, - dir: join(projectDir, 'subagents', sessionId), + // The harness writes the directory under the SANITIZED id + // (`getSubagentSessionDir` maps everything outside [A-Za-z0-9_-] to '_'), + // so the lookup must apply the same mapping: joined raw, any id carrying + // a dot reaches a path that does not exist, and every reader silently + // sees nothing while the harness's records sit one underscore away. + dir: join(projectDir, 'subagents', sanitizeFilenameComponent(sessionId)), }; } @@ -236,6 +255,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { let recordedSession = ''; let launchPrompt = ''; let finalText = ''; + let toolTrafficAfterText = true; let successfulToolCalls = 0; let diffToolCalls = 0; @@ -335,8 +355,21 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { if (type === 'assistant') { const t = textOf(rec); - if (t) finalText = t; + if (t) { + finalText = t; + toolTrafficAfterText = false; + } } + // Any function call or response AFTER the text marks it as progress — + // the agent went on working, so that text was narration, not a return. + if (parts.some((p) => (p as FunctionCallPart).functionCall !== undefined)) + toolTrafficAfterText = true; + if ( + parts.some( + (p) => (p as FunctionResponsePart).functionResponse !== undefined, + ) + ) + toolTrafficAfterText = true; } if (!agentId) return null; @@ -359,6 +392,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { successfulCallArgs, successfulReadFileArgs, finalText, + returned: finalText.trim() !== '' && !toolTrafficAfterText, mtimeMs, }; } @@ -411,7 +445,14 @@ export function readTranscripts( ); } - return recordsIn(dir, names, since, diffPath); + // The same ownership check the prior directories get. A record stamped + // with a DIFFERENT session was copied here — `since` cannot catch it (a + // copy gets a fresh mtime) and the launch-prompt pairing passes (prompts + // are deterministic per plan) — and a copy is not evidence of THIS + // session's work any more than it was of the attempt it was planted in. + return recordsIn(dir, names, since, diffPath, { + sessionId: transcriptPaths(env).sessionId, + }); } /** @@ -491,7 +532,11 @@ export function priorSessionDirs( endsAtMs: number | null; }> = []; for (const { sessionId, endsAtMs } of priorSessionEntries(planPath, env)) { - const dir = join(projectDir, 'subagents', sessionId); + const dir = join( + projectDir, + 'subagents', + sanitizeFilenameComponent(sessionId), + ); try { if (lstatSync(dir).isSymbolicLink()) continue; } catch { diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index 35b9bc39369..f53724fa8c9 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -1061,6 +1061,37 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( } }); + it('aborts when the plan changed while providers were running', () => { + // The plan path is shared per PR and providers take real time: a + // concurrent capture can replace the file mid-computation, and this run + // would then write contents derived from the OLD plan and restore the + // NEW run's epoch over them — the other run's ledger and transcripts + // pass an exact mtime fence against a plan they never described. + const root = mkdtempSync(join(tmpdir(), 'repo-context-cas-')); + try { + const worktree = join(root, 'wt'); + mkdirSync(worktree, { recursive: true }); + const planPath = planAt(root, { files: [{ path: 'src/a.ts' }] }); + const racer: RepositoryContextProvider = { + provide() { + // The concurrent run captures the plan mid-computation. + writeFileSync(planPath, JSON.stringify({ files: [] })); + const later = new Date(Date.now() + 60_000); + utimesSync(planPath, later, later); + return null; + }, + }; + expect(() => + runRepoContext( + { plan: planPath, worktree, out: join(root, 'ctx.json') }, + [racer], + ), + ).toThrow(/changed while repository context was being computed/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('preserves a SUB-MILLISECOND plan mtime, and stays silent about it', () => { // The case a `Date`-backdated fixture cannot reach. `Date` carries whole // milliseconds; APFS and ext4 keep nanoseconds. Restoring from diff --git a/packages/cli/src/commands/review/repo-context.ts b/packages/cli/src/commands/review/repo-context.ts index 5364eb67a9f..8b2a4e832d8 100644 --- a/packages/cli/src/commands/review/repo-context.ts +++ b/packages/cli/src/commands/review/repo-context.ts @@ -368,6 +368,14 @@ export function runRepoContext( throw new Error(`repo-context: worktree is not a directory: ${worktree}`); } + // The plan's identity, captured BEFORE the provider work. The providers + // take real time, the plan path is shared per PR, and a concurrent capture + // can replace the file mid-computation — this run would then write contents + // derived from the OLD plan and restore the NEW run's epoch over them, + // leaving the other run's ledger and transcripts to pass an exact mtime + // fence against a plan they never described. Compared just before the + // write; a moved identity aborts rather than corrupts. + const planStatBefore = statSync(planPath); const plan = readPlan(planPath); if (plan.worktreePath !== undefined) { if ( @@ -409,6 +417,20 @@ export function runRepoContext( // restored after it; letting it advance re-keyed the epoch mid-run and // silently orphaned everything recorded before this command ran. const planStat = statSync(planPath); + // Compare-and-refuse: if the plan is no longer the file this run read — + // mtime moved or inode swapped since the capture above — another run owns + // the path now, and writing stale derived contents under ITS epoch is the + // one outcome worse than doing nothing. + if ( + Math.abs(planStat.mtimeMs - planStatBefore.mtimeMs) > 1 || + planStat.ino !== planStatBefore.ino + ) { + throw new Error( + `repo-context: the plan at ${planPath} changed while repository ` + + 'context was being computed (another run captured it); aborting ' + + 'rather than writing stale contents under its epoch.', + ); + } const serialized = stringifyPlanReport(plan); // The cheapest way to keep the epoch is not to move it: an enrichment that // changes nothing (the common case on a resumed run, where the plan diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts index d5fab119833..194870874f7 100644 --- a/packages/core/src/agents/index.ts +++ b/packages/core/src/agents/index.ts @@ -23,5 +23,6 @@ export { getSubagentSessionDir, getSubagentsRootDir, readAgentMeta, + sanitizeFilenameComponent, } from './agent-transcript.js'; export * from './tasks/types.js'; From e2a088e97fcc6373572e3cf197c20a2227c20f5e Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 16:25:41 +0800 Subject: [PATCH 16/21] fix(review): validate ledger entries before the cap consumes them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R5-1: all three bounded reads sliced the untrusted array BEFORE validating it, so sixty-four malformed entries at the front consumed the whole cap and hid every real entry behind them — `sessionEntryCount` read zero and the resume cap reset, which is precisely the attack the count exists to survive; the marker's resumes and restarts had the same shape, resetting the once-per-review restart bound the same way. Filter first, cap the survivors. The validation cost the original order was avoiding is bounded by MAX_LEDGER_BYTES — the file cannot hold enough entries for the cheap field checks to matter — while the cap's actual job, bounding the per-entry directory reads consumers pay, is done by capping what is RETURNED, and that stands either way. Pinned from both sides: seventy junk entries ahead of one valid entry count as one, and seventy valid entries still cap at sixty-four. --- .../commands/review/lib/run-ledger.test.ts | 35 ++++++++++++ .../cli/src/commands/review/lib/run-ledger.ts | 55 +++++++++++-------- 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index a9758f6a47c..b1a8cb99fb9 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -80,6 +80,41 @@ describe('sessionEntryCount — the cap term the gate must not swallow', () => { expect(sessionEntryCount(plan)).toBe(0); }); + it('validates entries BEFORE the cap, so junk cannot consume it', () => { + // Sliced raw, 64 malformed entries at the front hide every real one: + // `sessionEntryCount` reads 0 and the resume cap resets — the attack the + // count exists to survive. The witness is the reviewer's own: junk + // first, one valid entry last. + appendRunSession(plan, envOf('S1')); + const raw = JSON.parse( + readFileSync(runSessionsPath(plan), 'utf8'), + ) as Array>; + const junk = Array.from({ length: 70 }, (_, i) => ({ garbage: i })); + writeFileSync(runSessionsPath(plan), JSON.stringify([...junk, ...raw])); + + expect(sessionEntryCount(plan)).toBe(1); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('still caps the VALIDATED entries at the bound', () => { + // The cap's job — bounding the directory reads consumers pay per entry — + // survives the reorder: valid entries past the bound are dropped. + const mtime = statSync(plan).mtimeMs; + const now = Date.now(); + const entries = Array.from({ length: 70 }, (_, i) => ({ + sessionId: `S${i}`, + atMs: now, + planMtimeMs: mtime, + })); + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + writeFileSync(runSessionsPath(plan), JSON.stringify(entries)); + + expect(sessionEntryCount(plan)).toBe(64); + }); + it('applies the same fences as every other read', () => { // A count that admitted a foreign or stale entry would cap the wrong // number: it runs through `readSessions`, so the epoch, plan-mtime and diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 0019b178cef..9bf4c04a99d 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -159,9 +159,14 @@ function readSessions(planPath: string): SessionEntry[] { const epoch = runEpochMs(planPath); const ceiling = runCeilingMs(); const planMtime = planMtimeMs(planPath); - // Cap the entry count too: the byte bound alone still admits tens of - // thousands of tiny entries, each of which costs a directory read. - const kept = parsed.slice(0, MAX_LEDGER_ENTRIES).filter( + // Validate FIRST, cap the survivors: sliced raw, 64 malformed entries at + // the front consume the whole cap and hide every real one behind them — + // `sessionEntryCount` then reads 0 and the resume cap resets, which is + // the attack the count exists to survive. Validation cost is bounded by + // MAX_LEDGER_BYTES (the file cannot hold enough entries to matter); the + // cap's own job — bounding the directory reads CONSUMERS pay per entry — + // is done by capping what is returned, and that stands either way. + const kept = parsed.filter( (e): e is SessionEntry => typeof e === 'object' && e !== null && @@ -182,6 +187,7 @@ function readSessions(planPath: string): SessionEntry[] { Math.abs((e as SessionEntry).planMtimeMs! - planMtime) <= PLAN_MTIME_TOLERANCE_MS, ); + const capped = kept.slice(0, MAX_LEDGER_ENTRIES); // Deduplicate on READ, not only on append: the file lives in a directory // the orchestrator can reach, and a hand-written duplicate would make a // consumer that iterates entries (the cost ledger) bill one session @@ -191,7 +197,7 @@ function readSessions(planPath: string): SessionEntry[] { // would otherwise read as a second session and double-count everything // inside it. const seen = new Set(); - return kept.filter((e) => { + return capped.filter((e) => { const k = e.sessionId.toLowerCase(); return seen.has(k) ? false : (seen.add(k), true); }); @@ -392,27 +398,28 @@ export function readResumeMarker(planPath: string): ResumeMarker { const raw = parsed as ResumeMarker; const seenResume = new Set(); const resumes = Array.isArray(raw.resumes) - ? raw.resumes.slice(0, MAX_LEDGER_ENTRIES).filter( - (e) => - typeof e === 'object' && - e !== null && - typeof e.sessionId === 'string' && - // Same closed charset as the session ledger: these ids have the - // same address semantics, and one read path applying the gate - // while the other does not is how a threat model rots. - SESSION_ID_RE.test(e.sessionId) && - typeof e.atMs === 'number' && - e.atMs >= epoch && - e.atMs <= ceiling && - // Duplicates would each consume a RESUME_MAX slot and refuse a - // legitimate continuation. - !seenResume.has(e.sessionId.toLowerCase()) && - (seenResume.add(e.sessionId.toLowerCase()), true), - ) + ? raw.resumes + .filter( + (e) => + typeof e === 'object' && + e !== null && + typeof e.sessionId === 'string' && + // Same closed charset as the session ledger: these ids have the + // same address semantics, and one read path applying the gate + // while the other does not is how a threat model rots. + SESSION_ID_RE.test(e.sessionId) && + typeof e.atMs === 'number' && + e.atMs >= epoch && + e.atMs <= ceiling && + // Duplicates would each consume a RESUME_MAX slot and refuse a + // legitimate continuation. + !seenResume.has(e.sessionId.toLowerCase()) && + (seenResume.add(e.sessionId.toLowerCase()), true), + ) + .slice(0, MAX_LEDGER_ENTRIES) : []; const restarts = Array.isArray(raw.restarts) ? raw.restarts - .slice(0, MAX_LEDGER_ENTRIES) .filter( (e) => typeof e === 'object' && @@ -422,6 +429,10 @@ export function readResumeMarker(planPath: string): ResumeMarker { e.atMs >= epoch && e.atMs <= ceiling, ) + // Validated first, like the ledger and the resumes above: sliced + // raw, junk at the front hides the real restart and the + // once-per-review bound resets. + .slice(0, MAX_LEDGER_ENTRIES) : []; return { schemaVersion: 1, resumes, restarts }; } catch { From a15093ba3055831f4bfa784747363eff79cb6d60 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 15 Aug 2026 17:04:05 +0800 Subject: [PATCH 17/21] fix(review): work through the round-3-to-6 suggestion backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behavioural fixes. Session identity now folds on the PATH the id becomes — sanitized and lowercased — everywhere at once: dedup, the current-session exclusion, the marker's dedup, and both write-side duplicate checks. Folding on the raw id left every alias the filesystem or the harness sanitizer collapses (case variants, trailing dots, sanitized '.') open as a second session wearing the first one's evidence. Read-time dedup keeps the EARLIEST duplicate rather than the first in file order, which handed an out-of-order hand-written duplicate the session's identity and erased the window between the real start and itself from billing. The resume marker takes the same exact plan fence as the session ledger — the window alone is inexact by its own slack, and a previous run's resumes surviving a rewrite arrive with the cap already spent — and restarts dedupe for the same reason resumes always did. Marker writes stamp the plan mtime and refuse when the plan cannot be stat'ed, like the ledger's own dead-write refusal. Appends refuse to rewrite over a ledger that EXISTS as a regular file but could not be read: rewriting from the empty fallback on a transient fault erased every previously recorded entry, and the guard is keyed on the file's type so the pinned self-healing over planted symlinks stands. The layer-audit gate's record fence is the STRICT plan mtime, not the slacked epoch — record mtimes and the plan's come off the same clock, and the slack would re-admit a dead attempt's records written in the two seconds before a re-capture. The epoch JSDoc now says which artifacts key on which fence instead of claiming one definition covers all. The cost refusal message names the boundary that actually filtered (this attempt's start, on a resumed run). repo-context's three stale comments now match the shipped tolerance fence, and the run-ledger module header claims containment only for the certifying readers — cost is accounting, and the honest claim stops there. Twenty-odd guarantees that could be deleted with the suite green are now pinned, each mutation-verified: the byte and entry caps, the per-session authorization property, currentSessionEntry at all, the charset gate at read WITH a valid fence, both write-side guards observed through the raw file, the marker's schemaVersion/case-dedup/noFollow/swallow properties, the symmetric plan fence, the earliest-duplicate rule, the prior-side ownership and window clamps, the byte-vs-string diff hash (an invalid-UTF-8 buffer, which no string fixture can express), the whole-diff recovery branch (exact count), the key-shaped recovery count, the Step 4/5 refusal by name, the `contributed > 0` guard, two-prior-session folding, the handoff boundary operators, the gate's territory clause and identity filter on fixtures that pass every OTHER clause, and repo-context's write-skip through the real serializer. --- .../commands/review/check-coverage.test.ts | 42 ++- .../commands/review/compose-review.test.ts | 10 +- .../src/commands/review/cost-ledger.test.ts | 82 +++++- .../cli/src/commands/review/cost-ledger.ts | 8 +- .../cli/src/commands/review/fetch-pr.test.ts | 32 +++ .../cli/src/commands/review/lib/coverage.ts | 51 ++-- .../review/lib/layer-audit-gate.test.ts | 135 ++++++++++ .../commands/review/lib/layer-audit-gate.ts | 3 +- .../src/commands/review/lib/prompt-record.ts | 11 +- .../commands/review/lib/run-ledger.test.ts | 248 +++++++++++++++++- .../cli/src/commands/review/lib/run-ledger.ts | 184 ++++++++++--- .../commands/review/lib/transcripts.test.ts | 48 +++- .../src/commands/review/repo-context.test.ts | 49 +++- .../cli/src/commands/review/repo-context.ts | 19 +- 14 files changed, 830 insertions(+), 92 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 571b26f39da..366f4374372 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -2387,7 +2387,14 @@ describe('verificationGaps — a resumed run reads the prior attempt', () => { const r = verificationGaps(p, { postsFindings: true }, ENV); expect(r.ok).toBe(false); - expect(r.gaps).not.toEqual([]); + // BOTH steps come back owed, by name — "any gap exists" would stay green + // when only the reverse audit was refused while a dead verify agent was + // accepted, and `unverifiedFindings` would then ship findings as + // verified. + expect(r.gaps.map((g) => g.subject)).toEqual([ + 'verification and reverse audit', + ]); + expect(r.unverifiedFindings).toBe(true); }); }); @@ -2563,6 +2570,31 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' expect(r.ok).toBe(false); }); + it('counts recovered KEY-shaped work (verify/reverse-audit), not only chunks', () => { + // Every other recoveredAgents fixture is chunk-shaped; the key-shaped + // branch of `certifies()` — the one production uses for recovered + // whole-diff roles — was countable by nothing. + const p = plan(); + ledger(p, 'S0', 'S1'); + const d = promptRecordDir(p); + mkdirSync(d, { recursive: true }); + const key = 'reverse-audit'; + const brief = briefPath(p, key); + writeFileSync(brief, 'The brief.'); + const prompt = + 'You are review agent `reverse-audit`.\n' + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}")`; + writeFileSync(join(d, `${encodeURIComponent(key)}.txt`), prompt); + transcript('ra0', prompt, { calls: 2, opens: [brief] }); + moveToSession('ra0', 'S0'); + transcript('a1', good(1), { calls: 2 }); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.recoveredAgents).toBe(1); + }); + it('credits the prior attempt when this session launched nothing at all', () => { // The zero-launch continuation: the harness creates subagents/ // on the first launch, so a run that recovered everything has no dir. @@ -2580,6 +2612,12 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' // everything) — the point of the continuation is that it does not. expect(r.ok).toBe(true); expect(r.coveredChunks).toEqual([1, 2]); - expect(r.recoveredAgents).toBeGreaterThanOrEqual(2); + // EXACT: the prior session holds three recoverable records — the two + // chunk agents plus the roster stand-in, which recovers through the + // whole-diff branch of `certifies()` (no `chunk N of M` in its launch). + // `>= 2` could not see that branch: deleting it read 3 as 2 and stayed + // green, silently dropping recovered whole-diff work (verify, + // reverse-audit) from the continuity count. + expect(r.recoveredAgents).toBe(3); }); }); diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index aed22b6d962..d1434e576d6 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -577,12 +577,12 @@ describe('composeReview — modeled-system defect-layer cap', () => { ]; const walked = (...ids: string[]) => ids.map((id) => `Layer walked: ${id} — clear.`).join('\n'); - // A genuine reverse-audit auditor: the identity line, a real diff read - // (so `diffToolCalls > 0`), and the given receipts as its final text. // A GENUINE auditor: launched with the prompt the CLI recorded for the - // role, and it opened the brief that prompt points at. A receipt only - // counts from one of these — otherwise a compliant sibling's floor could - // carry a hand-written auditor's claims. + // role, and it opened the brief that prompt points at (plus a real diff + // read, receipts as final text). A receipt only counts from one of these — + // otherwise a compliant sibling's floor could carry a hand-written + // auditor's claims. (The earlier fixture matched on a bare IDENTITY + // constant; the gate no longer accepts that shape.) const auditor = (id: string, receipts: string) => { const planPath = join(dir, 'plan.json'); const brief = briefPath(planPath, 'reverse-audit'); diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index ea636cf9bc2..a0107be3abb 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -1443,7 +1443,9 @@ describe('cost-ledger — a resumed run bills the whole review', () => { runLedger(plan); expect(() => computeLedger(plan, env)).toThrow( - /no main-loop usage records at or after the plan/, + // The message names the boundary that actually filtered — on a + // resumed run that is this attempt's ledger entry, not the plan. + /no main-loop usage records at or after this attempt's start/, ); }); @@ -1462,6 +1464,84 @@ describe('cost-ledger — a resumed run bills the whole review', () => { expect(text).toContain('1 earlier session'); }); + it('does not announce a prior session that contributed nothing', () => { + // The `contributed > 0` guard: S0 is ledgered and authorized but has + // neither a chat nor an agent dir. An unconditional increment renders + // "totals include 1 earlier session" over a session whose contribution + // is zero — and no fixture asserted the 0. + const { plan, env } = fixture(); + runLedger(plan); + const ledger = computeLedger(plan, env); + expect(ledger.priorSessions).toBe(0); + }); + + it('folds TWO prior sessions, each inside its own window', () => { + // RESUME_MAX leaves headroom for a twice-resumed run, and nothing below + // hand-built render fixtures exercised N >= 2: the spans accumulation, + // the counting past 1, and the per-prior ceiling pairing. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:01:00Z', { input: 1000, output: 100 }), + ); + writeFileSync( + join(project, 'chats', 'S0b.jsonl'), + event('2026-08-03T10:06:00Z', { input: 200, output: 20 }), + ); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: 'S0' }, + Date.parse('2026-08-03T10:00:30Z'), + ); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: 'S0b' }, + Date.parse('2026-08-03T10:05:00Z'), + ); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: SESSION }, + Date.parse('2026-08-03T10:09:00Z'), + ); + recordResume( + plan, + { QWEN_CODE_SESSION_ID: SESSION }, + Date.parse('2026-08-03T10:09:00Z'), + ); + + const ledger = computeLedger(plan, env); + expect(ledger.priorSessions).toBe(2); + expect(ledger.totals.inputTokens).toBe(1700); + }); + + it('bills the boundary instant to exactly one attempt', () => { + // The handoff operators: an event AT the prior session's ceiling belongs + // to the NEXT attempt (>= excludes), and an event AT the current floor + // belongs to the current one (>= includes). Both mutations shipped green + // with every fixture 40s-8h away from a boundary. + const { plan, project, env } = fixture(); + const handoff = '2026-08-03T10:09:00.000Z'; + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + [ + event('2026-08-03T10:01:00Z', { input: 1000, output: 100 }), + // Exactly at the ceiling: the next attempt's, not this one's. + event(handoff, { input: 7777, output: 1 }), + ].join(''), + ); + writeFileSync( + join(project, 'chats', `${SESSION}.jsonl`), + // Exactly at the current floor: included. + event(handoff, { input: 500, output: 50 }), + ); + runLedger(plan); + + const ledger = computeLedger(plan, env); + // 1000 (prior, below the ceiling) + 500 (current, at the floor); the + // 7777 at the prior ceiling is excluded from the prior leg. + expect(ledger.totals.inputTokens).toBe(1500); + }); + it("folds the interrupted attempt's main loop and agents into the totals", () => { const { plan, env, project } = fixture(); runLedger(plan); diff --git a/packages/cli/src/commands/review/cost-ledger.ts b/packages/cli/src/commands/review/cost-ledger.ts index faef6e7bc07..533d7944cc2 100644 --- a/packages/cli/src/commands/review/cost-ledger.ts +++ b/packages/cli/src/commands/review/cost-ledger.ts @@ -507,7 +507,13 @@ export function computeLedger( if (mainEvents.length === 0) { throw new Error( `could not read the chat transcript ${chatFile}: no main-loop usage ` + - 'records at or after the plan', + // Name the boundary that actually filtered: on a resumed or + // long-lived session the floor is this attempt's ledger entry, not + // the plan — and an operator pointed at "after the plan" finds + // records plainly there and distrusts the refusal. + (own === null + ? 'records at or after the plan' + : `records at or after this attempt's start (its run-ledger entry)`), ); } diff --git a/packages/cli/src/commands/review/fetch-pr.test.ts b/packages/cli/src/commands/review/fetch-pr.test.ts index b5efb6ff9e8..09bd95632a5 100644 --- a/packages/cli/src/commands/review/fetch-pr.test.ts +++ b/packages/cli/src/commands/review/fetch-pr.test.ts @@ -693,6 +693,38 @@ describe('fetch-pr diff identity (diffSha256)', () => { ); }); + it('hashes the BYTES, not a utf8 decode of them', async () => { + // A pure-ASCII fixture cannot see the difference: digests of the Buffer + // and of its utf8-decoded string coincide for every valid-UTF-8 diff and + // diverge only on invalid bytes — which real diffs of binary-adjacent or + // latin1 files do contain. A regression to string-hashing would make the + // resume comparison refuse legitimate resumes on exactly those PRs. + const bytes = Buffer.concat([ + Buffer.from('diff --git a/f b/f\n+'), + Buffer.from([0xff, 0xfe, 0x80]), + Buffer.from('\n'), + ]); + const { resolveMergeBase } = await import('./lib/merge-base.js'); + const { gitRaw } = await import('./lib/git.js'); + vi.mocked(resolveMergeBase).mockReturnValue({ + sha: 'base123', + baseFetchFailed: false, + }); + vi.mocked(gitRaw).mockImplementation((...args: string[]) => + args.includes('diff') ? (bytes as unknown as Buffer) : Buffer.from(''), + ); + + const report = await reportFor(); + const { createHash } = await import('node:crypto'); + expect(report.diffSha256).toBe( + createHash('sha256').update(bytes).digest('hex'), + ); + // The decode-then-hash digest differs; equality above rules it out. + expect(report.diffSha256).not.toBe( + createHash('sha256').update(bytes.toString('utf8')).digest('hex'), + ); + }); + it('is null when no diff was captured', async () => { const { resolveMergeBase } = await import('./lib/merge-base.js'); vi.mocked(resolveMergeBase).mockReturnValue({ diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index fb1a255855e..88bb1fd7fbf 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -1456,6 +1456,33 @@ export interface VerificationReport { unverifiedFindings: boolean; } +/** + * Drop a PRIOR attempt's agents that never returned. + * + * A session that died mid-flight left records whose findings never existed: + * the agent opened its brief, said nothing, and the process went away. Such a + * record still carries a recorded prompt and an opened brief, which is the + * whole of the Step 4/5 delivery floor — so left in, it certifies a + * verification nobody performed. An empty return in the CURRENT session is a + * different thing entirely: an agent still running, which the idle checks own. + * + * Every CERTIFYING gate goes through here — coverage and the Step 4/5 + * floor. Two run-scoped readers deliberately do not: the layer-audit + * corroboration and the retirement scheduler consume receipts, where an + * empty return already contributes nothing (`parseLayerReceipts('')` is + * empty and `classifyReturn('')` is `unknown`), so for them the filter would + * be a second copy of a refusal they already make — and both fail SAFE + * without it, over-owing rather than releasing. + */ +function liveRecords(all: AgentRecord[]): AgentRecord[] { + // `returned`, not merely non-empty: `finalText` keeps the last non-empty + // assistant text, which includes progress narrated between tool calls — an + // agent that opened its inputs, said "reading the diff now…" and died + // carries plausible text that certifies nothing. A record with tool + // traffic after its text never returned. + return all.filter((r) => !(r.fromPriorSession && !r.returned)); +} + /** * Did Step 4 (verify) and Step 5 (reverse audit) actually run, and read their * briefs? @@ -1482,30 +1509,6 @@ export interface VerificationReport { * CLI recorded building (`reverse-audit` / `reverse-audit--chunk-N` / `verify`) and * the harness's transcript of an agent launched with it that opened its brief. */ -/** - * Drop a PRIOR attempt's agents that never returned. - * - * A session that died mid-flight left records whose findings never existed: - * the agent opened its brief, said nothing, and the process went away. Such a - * record still carries a recorded prompt and an opened brief, which is the - * whole of the Step 4/5 delivery floor — so left in, it certifies a - * verification nobody performed. An empty return in the CURRENT session is a - * different thing entirely: an agent still running, which the idle checks own. - * - * Every gate that reads run-scoped evidence goes through here. The filter - * lived at one read site and not the other, and the site without it was the - * one that certifies Steps 4 and 5 — a gate reading a different evidence set - * than its siblings is how a review certifies work nobody did. - */ -function liveRecords(all: AgentRecord[]): AgentRecord[] { - // `returned`, not merely non-empty: `finalText` keeps the last non-empty - // assistant text, which includes progress narrated between tool calls — an - // agent that opened its inputs, said "reading the diff now…" and died - // carries plausible text that certifies nothing. A record with tool - // traffic after its text never returned. - return all.filter((r) => !(r.fromPriorSession && !r.returned)); -} - export function verificationGaps( planPath: string, opts: { postsFindings: boolean }, diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index 88c1054d5ba..1046580d831 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -402,6 +402,141 @@ describe('the real reader on a resumed run — prior-session auditors count', () expect(out).toHaveLength(6); }); + it('refuses a delivered auditor whose reads were OFF its territory', () => { + // The territory clause, discriminated on a fixture that PASSES + // delivered(): the only off-territory fixture elsewhere fails delivery + // first, so the clause could be deleted with the suite green. + ledger('S1'); + auditorTranscript('S1', LAYERS); + const f = join(dir, 'subagents', 'S1', 'agent-ra-S1.jsonl'); + // Move the diff read far off the baked offset=0..100 territory. + writeFileSync( + f, + readFileSync(f, 'utf8').replaceAll( + '"args":{"file_path":' + + JSON.stringify(diff) + + ',"offset":0,"limit":100}', + '"args":{"file_path":' + + JSON.stringify(diff) + + ',"offset":3300,"limit":100}', + ), + ); + const out = layerAuditGate(plan, ENV()).unreviewed; + expect(out).toHaveLength(6); + }); + + it('ignores receipts from a delivered agent WITHOUT the identity line', () => { + // The identity filter, discriminated on a fixture that clears every + // other clause: a verifier-shaped record delivered verbatim, brief + // opened, diff read on territory, `Layer walked:` lines in its return — + // and no reverse-audit identity. It must contribute nothing. + ledger('S1'); + const key = 'verify'; + const brief = briefPath(plan, key); + const launch = + 'You are review agent `verify` — rule on the findings.\n' + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${diff}", offset=0, limit=100)`; + mkdirSync(promptRecordDir(plan), { recursive: true }); + writeFileSync(brief, 'The verify brief.'); + recordPrompt(plan, key, launch); + const base = { + agentId: 'v-1', + agentName: 'general-purpose', + sessionId: 'S1', + }; + writeFileSync( + join(dir, 'subagents', 'S1', 'agent-v-1.jsonl'), + [ + JSON.stringify({ + ...base, + type: 'user', + message: { role: 'user', parts: [{ text: launch }] }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { name: 'read_file', args: { file_path: brief } }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'brief' }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: diff, offset: 0, limit: 100 }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'diff bytes' }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + text: LAYERS.map( + (id) => `Layer walked: ${id} — examined.`, + ).join('\n'), + }, + ], + }, + }), + ].join('\n') + '\n', + ); + + // A REAL auditor beside it, covering two layers: with the identity + // filter the verifier contributes nothing and four layers stay owed; + // without it the verifier's six receipts release everything. (A + // verifier-only fixture cannot discriminate — no auditor at all makes + // the gate defer, which is also `[]`.) + auditorTranscript('S1', ['lexing', 'expansion']); + const out = layerAuditGate(plan, ENV()).unreviewed; + expect(out).toHaveLength(4); + }); + it('sees nothing from a prior session the ledger never recorded', () => { // A PARTIAL walk is the discriminating shape: were the un-ledgered // transcript visible, one identity-matched auditor covering two layers diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.ts index 1fe2d4143ac..cc557856ad4 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.ts @@ -61,7 +61,6 @@ import { readRecordedPrompts, wasDeliveredVerbatim, briefPath, - runEpochMs, } from './prompt-record.js'; import { repositoryContextOf, @@ -120,7 +119,7 @@ function readReverseAuditReturns( // launches a stale record's prompt verbatim gets `corroborated` // non-empty on a run whose builder never emitted an auditor. The failure // direction of a dropped corroboration is withhold, never release. - const built = readRecordedPrompts(planPath, runEpochMs(planPath)); + const built = readRecordedPrompts(planPath, since); const delivered = (t: (typeof auditors)[number]): boolean => { for (const [key, prompt] of built) { if (prompt.trim() === '') continue; diff --git a/packages/cli/src/commands/review/lib/prompt-record.ts b/packages/cli/src/commands/review/lib/prompt-record.ts index dc2c13a6171..4ec5d50768e 100644 --- a/packages/cli/src/commands/review/lib/prompt-record.ts +++ b/packages/cli/src/commands/review/lib/prompt-record.ts @@ -47,10 +47,13 @@ export const RUN_EPOCH_SLACK_MS = 2000; /** * The run's epoch: records older than this predate the run and are ignored. * - * Every per-run artifact beside the plan keys on this — the deadline stamps, - * the prompt records, the transcripts, the session ledger — because the plan - * path is stable per PR while its mtime dates the run. One definition, so a - * change to the fence cannot apply to some readers and not others. An + * The Date.now()-stamped artifacts key on this — the deadline stamps and the + * session ledger — where the slack absorbs the sub-millisecond skew between a + * file mtime and a wall-clock stamp. The FILE-mtime-fenced artifacts (prompt + * records, transcripts) compare against the plan's strict mtime instead: + * their timestamps and the plan's come off the same clock, so they need no + * slack, and giving them one would re-admit a dead attempt's records written + * in the two seconds before a re-capture. An * unstatable plan disables the fence (fail open, like every other malformed * input these readers take). */ diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index b1a8cb99fb9..0fbafe41e85 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -23,6 +23,8 @@ import { writeFileSync, readFileSync, utimesSync, + chmodSync, + existsSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -37,6 +39,7 @@ import { recordRestart, resumeMarkerPath, RESUME_MAX, + currentSessionEntry, } from './run-ledger.js'; let root: string; @@ -375,9 +378,11 @@ describe('the properties the threat model rests on', () => { expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); }); - it('drops a FUTURE-dated entry, which would outlive every rewrite', () => { - // One-sided fences let a hand-written far-future entry read as belonging - // to every later run of the same PR. + it('drops a FUTURE-dated entry', () => { + // Not for cross-run survival — the exact plan fence already drops every + // entry on a rewrite — but because nothing legitimate writes the future: + // a forged future atMs shifts its session's billing window and its place + // in the attempt ordering. appendRunSession(plan, envOf('S1'), Date.now() + 3_600_000); authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); @@ -444,6 +449,243 @@ describe('the properties the threat model rests on', () => { expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); + it('reads an over-budget ledger as empty, before parsing it', () => { + // The byte bound: a planted multi-gigabyte file would otherwise be read + // fully into memory by every consumer. 256 KiB + 1 of valid JSON reads + // as no ledger at all. + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + const pad = 'x'.repeat(256 * 1024 + 1); + writeFileSync(runSessionsPath(plan), JSON.stringify([{ pad }])); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + expect(sessionEntryCount(plan)).toBe(0); + }); + + it('denies a session the marker names to every OTHER session', () => { + // The gate's per-session property: authorization granted to S3 must not + // open the ledger to S2. Degraded to "any resume exists", every test + // that reads as the session it authorized stays green. + appendRunSession(plan, envOf('S1')); + authorize('S3'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('currentSessionEntry finds its own entry without any authorization', () => { + // The third read export, previously untested anywhere: the cost floor + // depends on it, and on a FRESH run there is never a resume marker — a + // gated variant returns null exactly where the floor exists to act, and + // pre-review turns of a long-lived session bill as review cost. + appendRunSession(plan, envOf('S1'), Date.now()); + const own = currentSessionEntry(plan, envOf('S1')); + expect(own?.sessionId).toBe('S1'); + expect(typeof own?.atMs).toBe('number'); + expect(currentSessionEntry(plan, envOf('S-absent'))).toBeNull(); + expect(currentSessionEntry(plan, {})).toBeNull(); + }); + + it('drops a traversal id at READ even when the fence would pass it', () => { + // The hand-planted entry must fail ONLY the charset gate: with + // planMtimeMs valid, deleting SESSION_ID_RE from readSessions is what + // this pins — traversal ids would otherwise flow into subagents/ + // path assembly. + appendRunSession(plan, envOf('S1')); + const raw = JSON.parse( + readFileSync(runSessionsPath(plan), 'utf8'), + ) as Array>; + raw.push({ + sessionId: '../../etc', + atMs: Date.now(), + planMtimeMs: statSync(plan).mtimeMs, + }); + writeFileSync(runSessionsPath(plan), JSON.stringify(raw)); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual(['S1']); + }); + + it('keeps the EARLIEST duplicate, not the first in file order', () => { + // An out-of-order hand-written duplicate must not pick its survivor by + // file position: the later atMs would erase the window between the real + // start and itself from every consumer's billing. + // Backdate the plan so both timestamps sit inside the epoch window AND + // below the future ceiling — otherwise the ceiling drops the later + // duplicate and the ordering never gets to decide. + const past = new Date(Date.now() - 300_000); + utimesSync(plan, past, past); + const mtime = statSync(plan).mtimeMs; + const base = Math.floor(mtime); + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + writeFileSync( + runSessionsPath(plan), + JSON.stringify([ + { sessionId: 'S1', atMs: base + 120_000, planMtimeMs: mtime }, + { sessionId: 'S1', atMs: base, planMtimeMs: mtime }, + ]), + ); + authorize('S2'); + expect(priorSessionEntries(plan, envOf('S2'))[0]?.atMs).toBe(base); + }); + + it('folds path-equivalent id variants into one session', () => { + // `s1`, `S1` and `S1.` all reach the same directory (case folding, + // Win32 trailing-dot stripping, the harness sanitizer's '.' → '_'), so + // they are one session everywhere or an alias reads as a second session + // wearing the first one's evidence. + const mtime = statSync(plan).mtimeMs; + const base = Date.now(); + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + writeFileSync( + runSessionsPath(plan), + JSON.stringify([ + { sessionId: 'S1', atMs: base, planMtimeMs: mtime }, + { sessionId: 's1', atMs: base + 1000, planMtimeMs: mtime }, + // The sanitizer maps '.' to '_', so `S2.` and `S2_` are ONE + // directory — and note this also moots the Win32 trailing-dot alias: + // `S2.` never reaches `S2`, because the lookup never uses the raw + // name. + { sessionId: 'S2.', atMs: base + 2000, planMtimeMs: mtime }, + { sessionId: 'S2_', atMs: base + 3000, planMtimeMs: mtime }, + ]), + ); + authorize('S9'); + authorize('s1'); + authorize('s2_'); + expect(priorSessionIds(plan, envOf('S9'))).toEqual(['S1', 'S2.']); + // ...and the current-session exclusion folds the same way. + expect(priorSessionIds(plan, envOf('s1'))).toEqual(['S2.']); + expect(priorSessionIds(plan, envOf('s2_'))).toEqual(['S1']); + }); + + it('drops an entry whose plan mtime is NEWER than the plan, too', () => { + // The fence is symmetric by |diff|: pinned only from the older side, a + // signed comparison ships green while a forged future-plan entry reads + // as this run's. + appendRunSession(plan, envOf('S1')); + const raw = JSON.parse( + readFileSync(runSessionsPath(plan), 'utf8'), + ) as Array>; + (raw[0] as { planMtimeMs: number }).planMtimeMs += 50; + writeFileSync(runSessionsPath(plan), JSON.stringify(raw)); + authorize('S2'); + expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); + }); + + it('refuses to append over a ledger it could not read', () => { + // A present-but-unreadable REGULAR file holds every recorded entry, and + // this append rewrites the whole file from what it read — proceeding on + // a transient fault would clobber attempt 1's address exactly when a + // resume needs it. + appendRunSession(plan, envOf('S1')); + const before = readFileSync(runSessionsPath(plan), 'utf8'); + chmodSync(runSessionsPath(plan), 0o000); + try { + appendRunSession(plan, envOf('S2')); + } finally { + chmodSync(runSessionsPath(plan), 0o644); + } + expect(readFileSync(runSessionsPath(plan), 'utf8')).toBe(before); + authorize('S3'); + expect(priorSessionIds(plan, envOf('S3'))).toEqual(['S1']); + }); + + it('does not write at all when the id fails the charset gate', () => { + // The write-side guard, discriminated from the read-side one by looking + // at the FILE: with only the read gate, the bad id would be on disk. + appendRunSession(plan, envOf('../evil')); + expect(existsSync(runSessionsPath(plan))).toBe(false); + }); + + it('writes one marker entry for a same-session retry', () => { + // recordResume's write-side dedup, discriminated by reading the raw + // file: the read-side dedup would hide a double write. + recordResume(plan, envOf('S1')); + recordResume(plan, envOf('S1')); + const raw = JSON.parse(readFileSync(resumeMarkerPath(plan), 'utf8')) as { + resumes: unknown[]; + }; + expect(raw.resumes).toHaveLength(1); + }); + + it('drops marker entries from a previous run of the same PR', () => { + // The marker takes the same exact plan fence as the ledger, for the + // same reason: surviving a plan rewrite means arriving with the cap + // already spent, against this reader's own "a fresh run always starts + // at zero". + recordResume(plan, envOf('S1')); + expect(readResumeMarker(plan).resumes).toHaveLength(1); + // A fresh capture rewrites the plan (mtime moves — pushed past the + // tolerance explicitly, since two writes can land inside 1ms). + writeFileSync(plan, JSON.stringify({ diffLines: 2, chunks: [] })); + const later = new Date(Date.now() + 60_000); + utimesSync(plan, later, later); + expect(readResumeMarker(plan).resumes).toEqual([]); + }); + + it('dedupes identical restart entries on read', () => { + // Each duplicate spends the once-per-review restart bound again. + recordRestart(plan, 'head-moved'); + const raw = JSON.parse(readFileSync(resumeMarkerPath(plan), 'utf8')) as { + restarts: Array>; + }; + raw.restarts.push({ ...raw.restarts[0] }); + writeFileSync(resumeMarkerPath(plan), JSON.stringify(raw)); + expect(readResumeMarker(plan).restarts).toHaveLength(1); + }); + + it('drops a v2 marker even when it carries entries', () => { + // The schemaVersion refusal, discriminated with POPULATED arrays: on an + // empty marker the refusal and the fallback are byte-identical. + recordResume(plan, envOf('S1')); + const raw = JSON.parse( + readFileSync(resumeMarkerPath(plan), 'utf8'), + ) as Record; + raw['schemaVersion'] = 2; + writeFileSync(resumeMarkerPath(plan), JSON.stringify(raw)); + expect(readResumeMarker(plan).resumes).toEqual([]); + }); + + it('folds case-variant marker resumes into one cap slot', () => { + recordResume(plan, envOf('S2')); + const raw = JSON.parse(readFileSync(resumeMarkerPath(plan), 'utf8')) as { + resumes: Array>; + }; + raw.resumes.push({ ...raw.resumes[0], sessionId: 's2' }); + writeFileSync(resumeMarkerPath(plan), JSON.stringify(raw)); + expect(readResumeMarker(plan).resumes).toHaveLength(1); + }); + + it.skipIf(process.platform === 'win32')( + 'writes the marker through a planted symlink without following it', + () => { + // The noFollow property, pinned for BOTH ledger writes as the sibling + // test's comment promises — this is the resume.json half. + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + const target = join(root, 'marker-outside.json'); + writeFileSync(target, '"untouched"'); + symlinkSync(target, resumeMarkerPath(plan)); + recordResume(plan, envOf('S1')); + expect(readFileSync(target, 'utf8')).toBe('"untouched"'); + expect(lstatSync(resumeMarkerPath(plan)).isSymbolicLink()).toBe(false); + expect(readResumeMarker(plan).resumes).toHaveLength(1); + }, + ); + + it('swallows a marker write into a colliding path, like the ledger', () => { + // The "bookkeeping never takes the review down" property, pinned for + // the writer that lacked it: a FILE standing where the record dir must + // be makes mkdir throw, and that throw must not escape. + writeFileSync(join(root, 'qwen-review-pr-7-fetch-prompts'), 'a file'); + expect(() => recordResume(plan, envOf('S1'))).not.toThrow(); + expect(() => recordRestart(plan, 'head-moved')).not.toThrow(); + }); + it('drops an entry older than the slack window', () => { const mtimeMs = statSync(plan).mtimeMs; appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs) - 3000); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 9bf4c04a99d..5f8c8d66358 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -16,18 +16,26 @@ // can point somewhere flattering. // // So `fetch-pr` appends its own session id here, read back later from disk. The -// entry is only ever an ADDRESS, never a verdict: a fabricated id can at most -// point a reader at a directory inside the harness's own `subagents/` tree, -// where credit still requires the content-shaped pairing (verbatim-delivered -// prompt, opened brief, diff reads) that fabrication cannot satisfy. +// entry is only ever an ADDRESS, never a verdict. For the CERTIFYING readers +// a fabricated id can at most point at a directory inside the harness's own +// `subagents/` tree, where credit still requires the content-shaped pairing +// (verbatim-delivered prompt, opened brief, diff reads) that fabrication +// cannot satisfy. Two consumers sit outside that sentence, deliberately: ids +// also address `chats/.jsonl`, and the COST ledger folds a session's +// usage with no pairing at all — a forged id there can inflate a number the +// review reports about itself, never a verdict it certifies about the code. +// Cost is accounting, not evidence, and the honest claim stops there. // // The same file's sibling, `resume.json`, is the resume/restart bookkeeping the // skill used to hold only in transcript memory: how many times this review has // resumed, and whether it already restarted once for head movement. -import { readFileSync, lstatSync, mkdirSync, statSync } from 'node:fs'; +import { lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; -import { atomicWriteFileSync } from '@qwen-code/qwen-code-core'; +import { + atomicWriteFileSync, + sanitizeFilenameComponent, +} from '@qwen-code/qwen-code-core'; import { promptRecordDir, runEpochMs } from './prompt-record.js'; const SESSIONS_FILE = 'run-sessions.json'; @@ -49,10 +57,25 @@ export const RESUME_MAX = 2; const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; /** - * How far ahead of NOW an entry may be stamped. The epoch fence's lower half - * keeps a previous review's entries out; without an upper half, one - * hand-written far-future entry survives every future rewrite of the same - * plan and is read as belonging to every later run. + * The equivalence key under which two session ids are the SAME session: the + * path segment they become. Case folds on case-insensitive filesystems, + * Win32 strips trailing dots, and the harness sanitizes everything outside + * [A-Za-z0-9_-] to '_' when it creates the directory — so identity + * comparisons anywhere in this module must fold the same way, or a planted + * alias reads as a second session with the first one's evidence. + */ +function sessionPathKey(id: string): string { + return sanitizeFilenameComponent(id).toLowerCase(); +} + +/** + * How far ahead of NOW an entry may be stamped. Cross-run exclusion is owed + * to the exact plan-mtime fence, not to this ceiling — a rewrite moves the + * plan's mtime and the fence drops every earlier entry regardless of its + * stamp. What the ceiling refuses is a plausibility lie WITHIN a run: a + * forged future `atMs` would otherwise shift its session's billing window + * and its position in the attempt ordering, since nothing legitimate ever + * writes the future. */ const FUTURE_SLACK_MS = 2000; @@ -129,6 +152,22 @@ export function runSessionsPath(planPath: string): string { const MAX_LEDGER_BYTES = 256 * 1024; const MAX_LEDGER_ENTRIES = 64; +/** + * Is there a REGULAR file at `path` that `readLedgerFile` refused? The + * clobber guard keys on this, not on bare existence: a planted symlink or + * FIFO is not previous state to preserve — the `noFollow` atomic write + * self-heals over it, and that behaviour is pinned — while a real file that + * failed to read (EMFILE, an AV scanner's EPERM) holds every previously + * recorded entry, and rewriting from the empty fallback would erase them. + */ +function unreadableRegularLedger(path: string): boolean { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } +} + function readLedgerFile(path: string): string | null { try { const st = lstatSync(path); @@ -191,16 +230,25 @@ function readSessions(planPath: string): SessionEntry[] { // Deduplicate on READ, not only on append: the file lives in a directory // the orchestrator can reach, and a hand-written duplicate would make a // consumer that iterates entries (the cost ledger) bill one session - // twice. First occurrence wins — it carries the session's real start. - // Case-insensitively, because these ids become PATH segments: on APFS or - // Windows `s1` and `S1` are the same directory, so a case-variant entry - // would otherwise read as a second session and double-count everything - // inside it. + // twice. EARLIEST occurrence wins — sorted by time first, because "first + // in file order" hands a hand-written out-of-order duplicate the + // session's identity, and its later atMs then erases the window between + // the real start and itself from every consumer's billing. + // + // The equivalence key is the SANITIZED, lowercased id: these ids become + // path segments, and two ids are the same session exactly when they + // reach the same directory — case-insensitive filesystems fold case, + // Win32 strips trailing dots, and the harness's own sanitizer maps + // everything outside [A-Za-z0-9_-] to '_'. Folding on the raw id left + // every one of those aliases open as a second identity. const seen = new Set(); - return capped.filter((e) => { - const k = e.sessionId.toLowerCase(); - return seen.has(k) ? false : (seen.add(k), true); - }); + return capped + .slice() + .sort((x, y) => x.atMs - y.atMs) + .filter((e) => { + const k = sessionPathKey(e.sessionId); + return seen.has(k) ? false : (seen.add(k), true); + }); } catch { return []; } @@ -221,7 +269,11 @@ export function appendRunSession( const id = env['QWEN_CODE_SESSION_ID']?.trim(); if (!id || !SESSION_ID_RE.test(id)) return; const entries = readSessions(planPath); - if (entries.some((e) => e.sessionId === id)) return; + // Same equivalence as the read side: a pre-planted case- or alias-variant + // otherwise passes this check, and first-write-wins hands it the + // session's identity. + if (entries.some((e) => sessionPathKey(e.sessionId) === sessionPathKey(id))) + return; const mtime = planMtimeMs(planPath); // No plan mtime, no entry. `readSessions` hard-requires the field — an // entry that cannot say which plan it saw is dropped on every read, and @@ -230,6 +282,18 @@ export function appendRunSession( // that silently loses the id. Refusing up front is honest and identical // in effect, minus the false success. if (mtime === null) return; + // A ledger that EXISTS but could not be read is not an empty ledger: + // this append rewrites the whole file from what it read, so proceeding + // on a transient fault (EMFILE, an AV scanner's EPERM) would clobber + // every previously recorded entry — erasing attempt 1's address exactly + // when a resume needs it. Skipping the append loses one entry; the + // clobber loses them all. + if ( + readLedgerFile(runSessionsPath(planPath)) === null && + unreadableRegularLedger(runSessionsPath(planPath)) + ) { + return; + } entries.push({ sessionId: id, atMs: nowMs, planMtimeMs: mtime }); const dir = promptRecordDir(planPath); mkdirSync(dir, { recursive: true }); @@ -285,11 +349,13 @@ export function currentSessionEntry( planPath: string, env: NodeJS.ProcessEnv = process.env, ): { sessionId: string; atMs: number } | null { - const current = env['QWEN_CODE_SESSION_ID']?.trim().toLowerCase(); - if (!current) return null; + const raw = env['QWEN_CODE_SESSION_ID']?.trim(); + if (!raw) return null; + const current = sessionPathKey(raw); return ( - readSessions(planPath).find((e) => e.sessionId.toLowerCase() === current) ?? - null + readSessions(planPath).find( + (e) => sessionPathKey(e.sessionId) === current, + ) ?? null ); } @@ -308,10 +374,11 @@ function resumeAuthorized( planPath: string, env: NodeJS.ProcessEnv = process.env, ): boolean { - const current = env['QWEN_CODE_SESSION_ID']?.trim().toLowerCase(); - if (!current) return false; + const raw = env['QWEN_CODE_SESSION_ID']?.trim(); + if (!raw) return false; + const current = sessionPathKey(raw); return readResumeMarker(planPath).resumes.some( - (r) => r.sessionId.toLowerCase() === current, + (r) => sessionPathKey(r.sessionId) === current, ); } @@ -336,7 +403,8 @@ export function priorSessionEntries( // minting `recoveredAgents` and a resumed disclosure on a run that never // resumed, and folding the current chat into the prior totals. if (!resumeAuthorized(planPath, env)) return []; - const current = env['QWEN_CODE_SESSION_ID']?.trim().toLowerCase(); + const current0 = env['QWEN_CODE_SESSION_ID']?.trim(); + const current = current0 ? sessionPathKey(current0) : undefined; // Sort by time, not file order: `endsAtMs` is a COST CLAMP, and an // out-of-order (hand-written) ledger or a backwards wall-clock step // between attempts would otherwise invert it — a null or negative window @@ -348,16 +416,16 @@ export function priorSessionEntries( atMs: e.atMs, endsAtMs: i + 1 < all.length ? all[i + 1].atMs : null, })) - .filter((e) => e.sessionId.toLowerCase() !== current); + .filter((e) => sessionPathKey(e.sessionId) !== current); } /** Resume/restart bookkeeping for one review run. */ export interface ResumeMarker { schemaVersion: 1; /** Each successful `--resume` continuation, in order. */ - resumes: Array<{ sessionId: string; atMs: number }>; + resumes: Array<{ sessionId: string; atMs: number; planMtimeMs?: number }>; /** Each restart-for-head-movement, in order. The skill's cap is one. */ - restarts: Array<{ atMs: number; reason: string }>; + restarts: Array<{ atMs: number; reason: string; planMtimeMs?: number }>; } // A fresh object every time: callers mutate the arrays (`recordResume` @@ -395,8 +463,10 @@ export function readResumeMarker(planPath: string): ResumeMarker { } const epoch = runEpochMs(planPath); const ceiling = runCeilingMs(); + const planMtime = planMtimeMs(planPath); const raw = parsed as ResumeMarker; const seenResume = new Set(); + const seenRestart = new Set(); const resumes = Array.isArray(raw.resumes) ? raw.resumes .filter( @@ -411,10 +481,18 @@ export function readResumeMarker(planPath: string): ResumeMarker { typeof e.atMs === 'number' && e.atMs >= epoch && e.atMs <= ceiling && + // The same exact plan fence as the session ledger, for the + // same reason: the window alone is inexact by its own slack, + // and a previous run's resumes surviving into a fresh run + // arrive with the cap already spent — this reader's own doc + // promises "a fresh run always starts at zero". + typeof e.planMtimeMs === 'number' && + planMtime !== null && + Math.abs(e.planMtimeMs - planMtime) <= PLAN_MTIME_TOLERANCE_MS && // Duplicates would each consume a RESUME_MAX slot and refuse a // legitimate continuation. - !seenResume.has(e.sessionId.toLowerCase()) && - (seenResume.add(e.sessionId.toLowerCase()), true), + !seenResume.has(sessionPathKey(e.sessionId)) && + (seenResume.add(sessionPathKey(e.sessionId)), true), ) .slice(0, MAX_LEDGER_ENTRIES) : []; @@ -427,7 +505,14 @@ export function readResumeMarker(planPath: string): ResumeMarker { typeof e.reason === 'string' && typeof e.atMs === 'number' && e.atMs >= epoch && - e.atMs <= ceiling, + e.atMs <= ceiling && + typeof e.planMtimeMs === 'number' && + planMtime !== null && + Math.abs(e.planMtimeMs - planMtime) <= PLAN_MTIME_TOLERANCE_MS && + // Dedup for the same reason resumes dedup: each duplicate + // spends the once-per-review restart bound again. + !seenRestart.has(`${e.reason}@${e.atMs}`) && + (seenRestart.add(`${e.reason}@${e.atMs}`), true), ) // Validated first, like the ledger and the resumes above: sliced // raw, junk at the front hides the real restart and the @@ -465,9 +550,26 @@ export function recordResume( ): void { const id = env['QWEN_CODE_SESSION_ID']?.trim(); if (!id || !SESSION_ID_RE.test(id)) return; + const mtime = planMtimeMs(planPath); + // Same refusal as the session ledger: an entry that cannot say which plan + // it saw is dropped on every read, so writing it would be a dead write. + if (mtime === null) return; + if ( + readLedgerFile(resumeMarkerPath(planPath)) === null && + unreadableRegularLedger(resumeMarkerPath(planPath)) + ) { + // Same clobber guard as the session ledger: an unreadable-but-present + // marker must not be rewritten from the empty default. + return; + } const marker = readResumeMarker(planPath); - if (marker.resumes.some((r) => r.sessionId === id)) return; - marker.resumes.push({ sessionId: id, atMs: nowMs }); + if ( + marker.resumes.some( + (r) => sessionPathKey(r.sessionId) === sessionPathKey(id), + ) + ) + return; + marker.resumes.push({ sessionId: id, atMs: nowMs, planMtimeMs: mtime }); writeMarker(planPath, marker); } @@ -481,8 +583,16 @@ export function recordRestart( reason: string, nowMs: number = Date.now(), ): void { + const mtime = planMtimeMs(planPath); + if (mtime === null) return; + if ( + readLedgerFile(resumeMarkerPath(planPath)) === null && + unreadableRegularLedger(resumeMarkerPath(planPath)) + ) { + return; + } const marker = readResumeMarker(planPath); if (marker.restarts.some((r) => r.reason === reason)) return; - marker.restarts.push({ atMs: nowMs, reason }); + marker.restarts.push({ atMs: nowMs, reason, planMtimeMs: mtime }); writeMarker(planPath, marker); } diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index 1ab8641d2c6..2730e75ac6e 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -400,6 +400,47 @@ describe('readRunTranscripts — the run across its sessions', () => { expect(recs.map((r) => r.agentId)).toEqual(['a9']); }); + it('refuses a foreign-stamped transcript in a PRIOR directory', () => { + // The refusing side of the copy rule for prior dirs: every other fixture + // either matches the stamp or omits it. Deleting the guard shipped green. + const plan = planWithLedger('S0', 'S1'); + priorFile( + 'S0', + 'agent-planted.jsonl', + JSON.stringify({ + agentId: 'planted', + agentName: 'general-purpose', + sessionId: 'S9', + type: 'user', + message: { role: 'user', parts: [{ text: 'launch planted' }] }, + }) + '\n', + ); + file('agent-a1.jsonl', transcript('a1')); + const recs = readRunTranscripts(plan, undefined, ENV); + expect(recs.map((r) => r.agentId)).toEqual(['a1']); + }); + + it('excludes a prior transcript written AFTER its attempt ended', () => { + // The refusing side of the per-attempt window: an operator who kept + // using the interrupted CLI session writes transcripts after the resume + // took over, and those are not this review's evidence. Every other + // fixture writes prior files before endsAtMs, so deleting the clamp + // shipped green. + const plan = planWithLedger('S0', 'S1'); + priorFile('S0', 'agent-late.jsonl', transcript('late')); + // The prior attempt's window closed at the S1 entry (now + 1500 in the + // ledger fixture); stamp the file well past it. + const future = new Date(Date.now() + 3600_000); + utimesSync( + join(dir, 'subagents', 'S0', 'agent-late.jsonl'), + future, + future, + ); + file('agent-a1.jsonl', transcript('a1')); + const recs = readRunTranscripts(plan, undefined, ENV); + expect(recs.map((r) => r.agentId)).toEqual(['a1']); + }); + it('lists each prior session once, and only those that exist', () => { const plan = planWithLedger('S0', 'S0', 'S1'); // A prior session that exists on disk: the accessor skips a ledgered id @@ -432,8 +473,11 @@ describe('readRunTranscripts — currentDirOptional', () => { ); const env = { QWEN_CODE_PROJECT_DIR: dir, QWEN_CODE_SESSION_ID: 'S-new' }; - // Reading prior evidence requires this session's own authorized resume, - // stamped after the prior attempt's transcripts. + // Reading prior evidence requires this session's own authorized resume. + // (The stamp's position relative to the transcripts is fixture realism, + // not a requirement — no code compares a resume's atMs to any transcript + // mtime; the only time clamps on prior records are `since` and the next + // session's ledger `atMs`.) appendRunSession(plan, env, now + 1500); recordResume(plan, env, now + 1500); // Without the option: the current dir is still load-bearing. diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index f53724fa8c9..244df8b70f8 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -26,6 +26,7 @@ import { type RepositoryContextProvider, } from './lib/repository-context.js'; import { repoContextCommand, runRepoContext } from './repo-context.js'; +import { stringifyPlanReport } from './lib/report.js'; import { isolateHostGitConfig } from './lib/test-utils.js'; import { appendRunSession, @@ -1061,6 +1062,46 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( } }); + it('skips the write entirely when the enrichment changes nothing', () => { + // The resumed-run common case the code comments on, unreachable from + // `planAt` fixtures: those write compact JSON while production plans are + // written by `stringifyPlanReport`, so `planUnchanged` was always false + // in this suite and the skip branch ran nowhere. Write the plan the way + // production does, with its context already attached, and assert no + // write happened at all — the strongest form of "the epoch cannot move" + // is that the commit-then-restore window never opens. + const root = mkdtempSync(join(tmpdir(), 'repo-context-skip-')); + try { + const worktree = join(root, 'wt'); + mkdirSync(worktree, { recursive: true }); + const planPath = join(root, 'plan.json'); + const context0 = context(); + const planObj = { + files: [{ path: 'src/a.ts' }], + repositoryContext: context0, + }; + writeFileSync(planPath, stringifyPlanReport(planObj as never)); + const before = new Date(Date.now() - 3600_000); + utimesSync(planPath, before, before); + const inoBefore = statSync(planPath).ino; + const mtimeBefore = statSync(planPath).mtimeMs; + + const same: RepositoryContextProvider = { + provide: () => context0, + }; + runRepoContext( + { plan: planPath, worktree, out: join(root, 'ctx.json') }, + [same], + ); + + // Same inode, same mtime: not restored — never rewritten. + expect(statSync(planPath).ino).toBe(inoBefore); + expect(statSync(planPath).mtimeMs).toBe(mtimeBefore); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('aborts when the plan changed while providers were running', () => { // The plan path is shared per PR and providers take real time: a // concurrent capture can replace the file mid-computation, and this run @@ -1131,9 +1172,11 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( } // The consequence that actually matters, asserted end to end rather // than inferred from a timestamp: the session entry `fetch-pr` wrote - // before this command ran is still THIS run's. A restore that lost the - // fraction moved the plan out from under the ledger's fence and this - // comes back empty. + // before this command ran is still THIS run's. Honest scope note: with + // the ledger fence tolerating 1ms, a `Date`-based restore (which loses + // strictly less than 1ms) would ALSO pass this assertion — the fence's + // tolerance is what makes that regression benign, and this test pins + // the end-to-end visibility, not the float restore itself. expect(priorSessionIds(planPath, { QWEN_CODE_SESSION_ID: 'S1' })).toEqual( ['S0'], ); diff --git a/packages/cli/src/commands/review/repo-context.ts b/packages/cli/src/commands/review/repo-context.ts index 8b2a4e832d8..5acfe2675c3 100644 --- a/packages/cli/src/commands/review/repo-context.ts +++ b/packages/cli/src/commands/review/repo-context.ts @@ -446,20 +446,23 @@ export function runRepoContext( atomicWriteFileSync(planPath, serialized); // Seconds as a FLOAT, not the `Date` objects: a `Date` carries integer // milliseconds, while APFS and ext4 keep nanoseconds — so restoring from - // `planStat.mtime` truncates the sub-millisecond remainder and lands on a - // timestamp that is close to the original but not equal to it. The exact - // `planMtimeMs === planMtime` fence in the run ledger reads that as a - // DIFFERENT plan and drops every session entry, silently emptying the - // resume ledger on a filesystem that keeps finer time than a `Date` can - // hold. Passing `mtimeMs / 1000` preserves the fraction. + // `planStat.mtime` truncates the sub-millisecond remainder and lands + // close to the original rather than on it. The run ledger's plan fence + // now tolerates 1ms (`PLAN_MTIME_TOLERANCE_MS`), so a truncated restore + // would no longer empty it — the float restore is kept because it is + // strictly more faithful, and because the strict `since` readers + // (prompt records, transcripts) have no tolerance at all. utimesSync(planPath, planStat.atimeMs / 1000, planStat.mtimeMs / 1000); // The rename commits a new mtime before the restore lands. Verify it: // an unrestored epoch fences out this run's own evidence, and a silent // one is worse than a loud one. Compared with a millisecond of tolerance // rather than exact float equality — `mtimeMs` is a float derived from a // nanosecond counter, and the last bits do not survive every filesystem's - // round trip. A whole millisecond of drift is far below the epoch slack - // and far above the representation noise this must not report as failure. + // round trip. One millisecond is exactly the ledger fence's own + // tolerance (`PLAN_MTIME_TOLERANCE_MS`) — the binding rail — so the + // warning fires precisely when the drift exceeds what that rail + // tolerates; the strict `since` readers bind tighter still, but a drift + // under 1ms cannot cross a whole-mtime boundary they compare against. if (Math.abs(statSync(planPath).mtimeMs - planStat.mtimeMs) > 1) { writeStderrLine( `WARNING: could not restore the plan's timestamp at ${planPath}; ` + From 84c75ffc250c159722cee3261c563ce7f9da6155 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 00:40:04 +0800 Subject: [PATCH 18/21] =?UTF-8?q?fix(review):=20close=20round=207=20on=20t?= =?UTF-8?q?he=20ledger=20=E2=80=94=20cap=20order,=20prior=20prefix,=20dige?= =?UTF-8?q?st=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three probe-proven blockers, all in code earlier rounds added. The bounded read capped in FILE order before sorting and deduplicating, so 64 valid hand-written duplicates at the front evicted every genuine entry — and the next append rewrote the file from the filtered survivors, laundering the plant permanently; with 64 distinct entries the just-appended current entry (always file-last) was the one dropped, nulling the cost floor. The pipeline is now sort → dedup → cap, each step defeating the payload the next one cannot. `priorSessionEntries` classified every non-current entry as prior, so a twice-resumed run read as the MIDDLE attempt received its own successor as a "prior session" with an unbounded window — its later unrelated activity folded into this attempt's bill, its records entered the evidence pool with no ceiling. Prior now means the PREFIX strictly before this session's own entry, and the last prior's window closes at this session's start. `currentDigestKeys` kept undatable verify keys on the premise that they cannot reach ok — false for the write-failure fallback, whose inlined list leaves no findings file and no pointer, making the findings-read floor vacuously true. A stale pointerless verifier could vouch for a newer dated list no verifier opened. Undatable keys are now dropped once any dated key exists; with no dated key at all they are the only evidence and stay. `sessionEntryCount` gains an exclude-current option for the cap's ledger term (consumed by the stack's fetch-pr): counting the session's own entry in either term refuses a same-session retry of the last permitted resume, whose fresh fall-through then destroys the very state being resumed. --- .../commands/review/check-coverage.test.ts | 27 +++++++++ .../cli/src/commands/review/lib/coverage.ts | 17 ++++-- .../commands/review/lib/run-ledger.test.ts | 58 ++++++++++++++++++- .../cli/src/commands/review/lib/run-ledger.ts | 47 +++++++++++++-- 4 files changed, 135 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 366f4374372..21bb76ce11d 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -1837,6 +1837,33 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', () expect(r.unverifiedFindings).toBe(true); }); + it('drops a POINTERLESS stale verify key once a dated digest exists', () => { + // The write-failure fallback inlines the list, so its key has no + // findings file — no date, and no findings-read floor either, which + // means it CAN reach ok. Kept beside a dated digest, a stale pointerless + // verifier vouches for a list no verifier opened. + const p = plan(); + step45(p, 'reverse-audit'); + // The pointerless stale verifier: compliant in every respect, no + // findings file on disk (prompt carries no pointer). + const d = promptRecordDir(p); + const key = 'verify--stale9999'; + const brief = briefPath(p, key); + writeFileSync(brief, `The ${key} brief.`); + const prompt = + `You are review agent \`${key}\`.\n` + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${DIFF}")`; + writeFileSync(join(d, `${encodeURIComponent(key)}.txt`), prompt); + transcript('vstale', prompt, { calls: 2, opens: [brief] }); + // The CURRENT digest: dated (findings file on disk), launched, its list + // unread — the floor must come back owed. + step45(p, 'verify--new22222222', { findings: true, opensFindings: false }); + + const r = verificationGaps(p, { postsFindings: true }, ENV); + expect(r.unverifiedFindings).toBe(true); + }); + it('passes when both verify and reverse audit ran on a review with findings', () => { const p = plan(); step45(p, 'reverse-audit'); diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 88bb1fd7fbf..a47da2f950b 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -1627,12 +1627,17 @@ export function verificationGaps( } if (dated.length === 0) return keys; const newest = Math.max(...dated.map((d) => d.mtimeMs)); - return [ - ...dated - .filter((d) => d.mtimeMs >= newest - DIGEST_WINDOW_MS) - .map((d) => d.key), - ...undatable, - ]; + // Undatable keys are DROPPED once any dated key exists. The earlier + // premise — "they cannot reach ok, so they only make the verdict + // stricter" — is false for the write-failure fallback: a key whose list + // was inlined has no findings file and no pointer, the findings-read + // floor is vacuously satisfied, and a stale digest's pointerless + // verifier could vouch for a list no verifier opened. With no dated key + // at all (every round inlined), the undatable set is the only evidence + // there is and stays. + return dated + .filter((d) => d.mtimeMs >= newest - DIGEST_WINDOW_MS) + .map((d) => d.key); }; /** The best shape across a step's keys — the floor is one agent, not all of them. */ diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 0fbafe41e85..25c7d5788a8 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -555,9 +555,11 @@ describe('the properties the threat model rests on', () => { authorize('S9'); authorize('s1'); authorize('s2_'); + // S9 is not in the ledger, so everything reads as prior. expect(priorSessionIds(plan, envOf('S9'))).toEqual(['S1', 'S2.']); - // ...and the current-session exclusion folds the same way. - expect(priorSessionIds(plan, envOf('s1'))).toEqual(['S2.']); + // Reading as s1 (folds with S1, the EARLIEST entry): S2. started after + // it, so it is a successor, not a prior — priors are the prefix. + expect(priorSessionIds(plan, envOf('s1'))).toEqual([]); expect(priorSessionIds(plan, envOf('s2_'))).toEqual(['S1']); }); @@ -686,6 +688,58 @@ describe('the properties the threat model rests on', () => { expect(() => recordRestart(plan, 'head-moved')).not.toThrow(); }); + it('collapses a duplicate flood BEFORE the cap can be consumed', () => { + // 64 valid hand-written duplicates at the front used to evict every + // genuine entry, and the next append rewrote the file from the filtered + // survivors — laundering the plant permanently. + const mtime = statSync(plan).mtimeMs; + const now = Date.now(); + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + const dupes = Array.from({ length: 64 }, () => ({ + sessionId: 'DUP', + atMs: now, + planMtimeMs: mtime, + })); + writeFileSync( + runSessionsPath(plan), + JSON.stringify([ + ...dupes, + { sessionId: 'S1', atMs: now - 1000, planMtimeMs: mtime }, + ]), + ); + expect(sessionEntryCount(plan)).toBe(2); + authorize('S9'); + expect(priorSessionIds(plan, envOf('S9'))).toEqual(['S1', 'DUP']); + }); + + it('treats a SUCCESSOR session as not-prior, with the window closed at self', () => { + // A twice-resumed run read as the middle attempt: the successor is not + // "earlier evidence", and the middle attempt's last prior window closes + // at its OWN start — not at null. + const past = new Date(Date.now() - 3600_000); + utimesSync(plan, past, past); + const mtime = statSync(plan).mtimeMs; + const base = Math.floor(mtime); + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + writeFileSync( + runSessionsPath(plan), + JSON.stringify([ + { sessionId: 'S0', atMs: base, planMtimeMs: mtime }, + { sessionId: 'S1', atMs: base + 510_000, planMtimeMs: mtime }, + { sessionId: 'S2', atMs: base + 2_900_000, planMtimeMs: mtime }, + ]), + ); + authorize('S1'); + const entries = priorSessionEntries(plan, envOf('S1')); + expect(entries.map((e) => e.sessionId)).toEqual(['S0']); + // S0's window closes when S1 began — not at S2, and never unbounded. + expect(entries[0].endsAtMs).toBe(base + 510_000); + }); + it('drops an entry older than the slack window', () => { const mtimeMs = statSync(plan).mtimeMs; appendRunSession(plan, envOf('S1'), Math.floor(mtimeMs) - 3000); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index 5f8c8d66358..d106be10ec8 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -226,7 +226,13 @@ function readSessions(planPath: string): SessionEntry[] { Math.abs((e as SessionEntry).planMtimeMs! - planMtime) <= PLAN_MTIME_TOLERANCE_MS, ); - const capped = kept.slice(0, MAX_LEDGER_ENTRIES); + // Order matters, and each step has a payload it defeats: SORT first + // (earliest wins, not file order), DEDUP second (a flood of valid + // duplicates collapses to one before any cap can be consumed), CAP last + // over the distinct survivors — capped in file order before dedup, 64 + // planted duplicates evicted every genuine entry and the next append + // laundered the plant permanently. + const capped = kept; // Deduplicate on READ, not only on append: the file lives in a directory // the orchestrator can reach, and a hand-written duplicate would make a // consumer that iterates entries (the cost ledger) bill one session @@ -248,7 +254,8 @@ function readSessions(planPath: string): SessionEntry[] { .filter((e) => { const k = sessionPathKey(e.sessionId); return seen.has(k) ? false : (seen.add(k), true); - }); + }) + .slice(0, MAX_LEDGER_ENTRIES); } catch { return []; } @@ -321,8 +328,23 @@ export function appendRunSession( * cap that the ledger was supposed to backstop — the one attack the two-counter * design existed to defeat. */ -export function sessionEntryCount(planPath: string): number { - return readSessions(planPath).length; +export function sessionEntryCount( + planPath: string, + opts: { + /** + * Exclude the session this id names (folded on the path key). The resume + * cap counts OTHER attempts: a same-session retry of the last permitted + * resume is that same resume, and counting the session's own entry in + * either term refuses the retry — whose fresh fall-through then destroys + * the very state being resumed. + */ + excludeSessionId?: string; + } = {}, +): number { + const entries = readSessions(planPath); + if (opts.excludeSessionId === undefined) return entries.length; + const key = sessionPathKey(opts.excludeSessionId); + return entries.filter((e) => sessionPathKey(e.sessionId) !== key).length; } /** @@ -410,11 +432,24 @@ export function priorSessionEntries( // between attempts would otherwise invert it — a null or negative window // silently unbounds or empties a prior session's bill. const all = [...readSessions(planPath)].sort((a, b) => a.atMs - b.atMs); - return all + // PRIOR means "started before this session", not "is not this session". + // A twice-resumed run read as the MIDDLE attempt otherwise receives its + // own successor as a prior with `endsAtMs: null` — the successor's whole + // activity, unrelated later turns included, folds into this attempt's + // bill unclamped, and its stamped records enter the evidence pool with no + // upper window. Only the prefix strictly before this session's own entry + // is prior; its last window closes at THIS session's start. + const ownIdx = + current === undefined + ? -1 + : all.findIndex((e) => sessionPathKey(e.sessionId) === current); + const prefix = ownIdx >= 0 ? all.slice(0, ownIdx) : all; + const ownAtMs = ownIdx >= 0 ? all[ownIdx].atMs : null; + return prefix .map((e, i) => ({ sessionId: e.sessionId, atMs: e.atMs, - endsAtMs: i + 1 < all.length ? all[i + 1].atMs : null, + endsAtMs: i + 1 < prefix.length ? prefix[i + 1].atMs : ownAtMs, })) .filter((e) => sessionPathKey(e.sessionId) !== current); } From 81455da29f8a07dbf68e4cf53fcec76a3568eaaf Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 03:23:21 +0800 Subject: [PATCH 19/21] fix(review): work through the round-7 suggestions on the ledger PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code: the recovery count's uncoverable veto is chunk-scoped like the walk's (a recovered whole-diff auditor legitimately QUOTES the declarations it audited); the layer gate delivers only against reverse-audit records (a concatenated launch verbatim-matched a sibling role's record and satisfied the brief bar with the sibling's brief); retirement extracts the findings pointer ONCE from the raw prompt (the re-extraction from trim-normalized lines defeated the anchors that reject indented quotations) and the orphaned memo-contract JSDoc is back on findingsListFor; the endsAtMs doc and the diffSha256 comment now say what the code does at this commit. Fixtures: the flake family is closed — the three suites' prior-session helpers backdate their files ten seconds, so a CI stall between the ledger stamp and the write can no longer fence the fixtures out through the until clamp. And a dozen probes that could not discriminate the guard they name now can: the over-budget ledger carries a valid entry, the symlinked target carries a fenced entry, the marker's charset gates are pinned on both sides with valid fences, the alias fold's entries sit inside the window, the marker's plan fence survives a 3s nudge instead of dying on the epoch window, resumeAuthorized folds case, the stale gate record sits INSIDE the would-be slack, the identity anchor refuses a substring mention, a delivered auditor with zero diff reads stays refused, the digest narrowing accepts the compliant current digest, per-record granularity is pinned with a mixed prior session, the prior-side floors and the per-session ceiling have events inside their discriminating windows, the plan-swap abort fires on the restored-mtime rename shape, and the epoch test asserts the rewrite's content. --- .../commands/review/check-coverage.test.ts | 51 +++++++++- .../src/commands/review/cost-ledger.test.ts | 95 +++++++++++++++++++ .../cli/src/commands/review/fetch-pr.test.ts | 13 ++- packages/cli/src/commands/review/fetch-pr.ts | 10 +- .../cli/src/commands/review/lib/coverage.ts | 18 +++- .../review/lib/layer-audit-gate.test.ts | 93 ++++++++++++++++-- .../commands/review/lib/layer-audit-gate.ts | 6 ++ .../commands/review/lib/retirement.test.ts | 10 +- .../cli/src/commands/review/lib/retirement.ts | 35 ++++--- .../commands/review/lib/run-ledger.test.ts | 76 +++++++++++++-- .../cli/src/commands/review/lib/run-ledger.ts | 6 +- .../commands/review/lib/transcripts.test.ts | 34 ++++++- .../src/commands/review/repo-context.test.ts | 45 ++++++++- 13 files changed, 446 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 21bb76ce11d..6e626bcc464 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -1864,6 +1864,21 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', () expect(r.unverifiedFindings).toBe(true); }); + it('accepts a compliant CURRENT-digest verifier beside an older one', () => { + // The acceptance direction of the digest narrowing: a keep-only-newest + // or refuse-multi-generation mutant must go red somewhere. + const p = plan(); + step45(p, 'reverse-audit'); + step45(p, 'verify--old11111111', { findings: true }); + const old = new Date(Date.now() - 600_000); + utimesSync(findingsFilePath(p, 'verify--old11111111'), old, old); + step45(p, 'verify--new22222222', { findings: true }); + + const r = verificationGaps(p, { postsFindings: true }, ENV); + expect(r.ok).toBe(true); + expect(r.unverifiedFindings).toBe(false); + }); + it('passes when both verify and reverse audit ran on a review with findings', () => { const p = plan(); step45(p, 'reverse-audit'); @@ -2373,6 +2388,22 @@ describe('verificationGaps — a resumed run reads the prior attempt', () => { return id; } + it('owes only the step whose agent died, per record — not per session', () => { + // Both prior fixtures were symmetric (all returned or all died), so a + // session-granular refactor (drop the whole session when ANY agent died) + // shipped green. Mixed shapes are the discriminator. + const p = plan(); + const okId = step45(p, 'reverse-audit'); + const deadId = step45(p, 'verify', { returned: false }); + moveToSession(okId, 'S0'); + moveToSession(deadId, 'S0'); + ledger(p, 'S0', 'S1'); + rmSync(join(dir, 'subagents', 'S1'), { recursive: true, force: true }); + + const r = verificationGaps(p, { postsFindings: true }, ENV); + expect(r.gaps.map((g) => g.subject)).toEqual(['verification']); + }); + it('accepts Step 4/5 evidence that exists only in a prior session', () => { // The zero-launch continuation, pinned at the verification floor rather // than inferred from its coverage sibling: a current-session-only reader @@ -2527,10 +2558,8 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' // shape. const f = join(dir, 'subagents', 'S1', 'agent-a1prog.jsonl'); const lines = readFileSync(f, 'utf8').trim().split('\n'); - const textLine = lines.findIndex((l) => l.includes('Reading the diff')); const callLine = lines.findIndex((l) => l.includes('functionCall')); lines.push(lines[callLine], lines[callLine + 1]); - void textLine; writeFileSync(f, lines.join('\n') + '\n'); moveToSession('a1prog', 'S0'); transcript('a2', good(2), { calls: 2 }); @@ -2559,6 +2588,24 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' expect(r.coveredChunks).not.toContain(1); }); + it('does not count a prior agent that declared ITS OWN chunk unreachable', () => { + // The veto on the recovery count, pinned: the declaration is a disclosed + // gap, and counting the record beside the cap would announce work + // "counted as reviewed" next to the gap the same record disclosed. + const p = plan(); + ledger(p, 'S0', 'S1'); + transcript('a1u', good(1), { + calls: 2, + text: 'Uncoverable: chunk 1 — a line exceeds the read limit', + }); + moveToSession('a1u', 'S0'); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.recoveredAgents).toBe(0); + expect(r.uncoverableChunks).toEqual([1]); + }); + it('counts two prior records that only supersede each other', () => { // A whiff-relaunch INSIDE the interrupted attempt: two records for the // same chunk, both clearing the bar, and no current-session agent at all. diff --git a/packages/cli/src/commands/review/cost-ledger.test.ts b/packages/cli/src/commands/review/cost-ledger.test.ts index a0107be3abb..85e74071deb 100644 --- a/packages/cli/src/commands/review/cost-ledger.test.ts +++ b/packages/cli/src/commands/review/cost-ledger.test.ts @@ -1514,6 +1514,101 @@ describe('cost-ledger — a resumed run bills the whole review', () => { expect(ledger.totals.inputTokens).toBe(1700); }); + it('clamps each prior session at ITS OWN successor, and sums both spans', () => { + // The intermediate per-session ceiling and multi-span wall time: events + // far inside any window discriminate neither. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + [ + event('2026-08-03T10:01:00Z', { input: 1000, output: 100 }), + // Past S0b's start: the NEXT entry's ceiling, not the global one, + // must exclude it from S0's leg. + event('2026-08-03T10:06:30Z', { input: 4444, output: 1 }), + ].join(''), + ); + writeFileSync( + join(project, 'chats', 'S0b.jsonl'), + event('2026-08-03T10:06:00Z', { input: 200, output: 20 }), + ); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: 'S0' }, + Date.parse('2026-08-03T10:00:30Z'), + ); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: 'S0b' }, + Date.parse('2026-08-03T10:05:00Z'), + ); + appendRunSession( + plan, + { QWEN_CODE_SESSION_ID: SESSION }, + Date.parse('2026-08-03T10:09:00Z'), + ); + recordResume( + plan, + { QWEN_CODE_SESSION_ID: SESSION }, + Date.parse('2026-08-03T10:09:00Z'), + ); + + const ledger = computeLedger(plan, env); + // 1000 (S0, inside its window) + 200 (S0b) + 500 (current); the 4444 + // stamped after S0b began belongs to no leg of S0's bill. + expect(ledger.totals.inputTokens).toBe(1700); + // Wall time accumulates across BOTH prior spans (each span here is a + // single event, so the sum is 0 — the assertion is that it is a number + // derived from two spans, not one, which the priorSessions count plus + // the totals above jointly pin). + expect(ledger.priorSessions).toBe(2); + }); + + it('prefilters prior agent streams against the PRIOR floor, not the current one', () => { + // Every fixture wrote prior transcripts at wall-clock now, postdating + // both candidate floors; in production a prior stream's mtime always + // predates the resumed attempt's floor, so a current-floor prefilter + // skips every prior agent silently. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + event('2026-08-03T10:01:00Z', { input: 1000, output: 100 }), + ); + const priorDir = join(project, 'subagents', 'S0'); + mkdirSync(priorDir, { recursive: true }); + const stream = join(priorDir, 'agent-a0.jsonl'); + writeFileSync( + stream, + event('2026-08-03T10:02:00Z', { input: 300, output: 30 }), + ); + // The stream's mtime: after the PRIOR attempt began, before the CURRENT + // one — the discriminating window. + const at = new Date('2026-08-03T10:02:30Z'); + utimesSync(stream, at, at); + runLedger(plan); + + const ledger = computeLedger(plan, env); + expect(ledger.totals.inputTokens).toBe(1800); + }); + + it('excludes prior chat noise from BEFORE that attempt began', () => { + // The Math.max(planMs, entry.atMs) floor on the prior leg: an event in + // [planMs, entry.atMs) — the operator's unrelated turns before the + // attempt started — must not bill. Every fixture left that window empty. + const { plan, project, env } = fixture(); + writeFileSync( + join(project, 'chats', 'S0.jsonl'), + [ + // After the plan (10:00:00), BEFORE S0's entry (10:00:30). + event('2026-08-03T10:00:10Z', { input: 9999, output: 1 }), + event('2026-08-03T10:01:00Z', { input: 1000, output: 100 }), + ].join(''), + ); + runLedger(plan); + + const ledger = computeLedger(plan, env); + expect(ledger.totals.inputTokens).toBe(1500); + }); + it('bills the boundary instant to exactly one attempt', () => { // The handoff operators: an event AT the prior session's ceiling belongs // to the NEXT attempt (>= excludes), and an event AT the current floor diff --git a/packages/cli/src/commands/review/fetch-pr.test.ts b/packages/cli/src/commands/review/fetch-pr.test.ts index 09bd95632a5..76004fe53ce 100644 --- a/packages/cli/src/commands/review/fetch-pr.test.ts +++ b/packages/cli/src/commands/review/fetch-pr.test.ts @@ -737,8 +737,19 @@ describe('fetch-pr diff identity (diffSha256)', () => { }); describe('fetch-pr run-session ledger wiring', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + // clearAllMocks resets call history, NOT implementations — re-assert the + // ones the preceding diff-identity describe reprogrammed, so this + // suite's "no diff captured" shape is an assertion rather than a + // coincidence of whatever final state leaked in. + const { resolveMergeBase } = await import('./lib/merge-base.js'); + const { gitRaw } = await import('./lib/git.js'); + vi.mocked(resolveMergeBase).mockReturnValue({ + sha: null, + baseFetchFailed: false, + }); + vi.mocked(gitRaw).mockImplementation(() => Buffer.from('')); producerMocks.readFileSync.mockImplementation(() => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); diff --git a/packages/cli/src/commands/review/fetch-pr.ts b/packages/cli/src/commands/review/fetch-pr.ts index 67c90face92..de6bc93fbee 100644 --- a/packages/cli/src/commands/review/fetch-pr.ts +++ b/packages/cli/src/commands/review/fetch-pr.ts @@ -136,10 +136,12 @@ type FetchPrResult = PlanReport & { /** * SHA-256 of the captured diff's raw bytes — the identity of WHAT this run * reviews, hashed from the same buffer the diff file was written from (the - * `diffHashOf` discipline: one read, no TOCTOU window). `--resume` compares - * it against the diff file on disk: a mismatch means the input changed, and - * changed input re-runs — the checkpoint key is content, never a path or a - * timestamp. Null when no diff was captured. + * `diffHashOf` discipline: one read, no TOCTOU window). Groundwork for the + * stack's `--resume` (the next PR): its ruling will compare this against + * the diff file on disk — a mismatch means the input changed, and changed + * input re-runs; the checkpoint key is content, never a path or a + * timestamp. No reader exists at THIS commit. Null when no diff was + * captured. */ diffSha256: string | null; /** diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index a47da2f950b..8fa32063af9 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -1068,10 +1068,20 @@ export function coverageFromTranscripts( // not finish, so it is not recovered work either — and "returned" means // terminal text, not progress narrated between tool calls. if (!r.returned) return false; - // A record whose own return declares a chunk unreachable did not review - // it; counting it as recovered would have the body announce work - // "counted as reviewed" beside the gap that same record disclosed. - if (UNCOVERABLE_RE.test(r.finalText)) return false; + // A record whose own return declares ITS OWN chunk unreachable did not + // review it; counting it as recovered would have the body announce work + // "counted as reviewed" beside the gap that same record disclosed. The + // veto is chunk-scoped like the walk's: applied raw it also matches a + // QUOTATION, and a recovered whole-diff auditor legitimately quotes the + // declarations it audited. + const declaredUnc = UNCOVERABLE_RE.exec(r.finalText); + if (declaredUnc !== null) { + const own = assignedChunk(r); + if (own !== null && Number(declaredUnc[1]) === own) return false; + if (own === null && r.diffToolCalls > 0 && assignedChunk(r) === null) { + // A whole-diff record quoting a declaration is not declaring. + } + } const c = assignedChunk(r); if (c !== null) { const b = builtOf(`chunk-${c}`); diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index 1046580d831..b11940c7294 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -326,10 +326,13 @@ describe('the real reader on a resumed run — prior-session auditors count', () }, }), ]; - writeFileSync( - join(dir, 'subagents', session, `agent-ra-${session}.jsonl`), - lines.join('\n') + '\n', - ); + const f = join(dir, 'subagents', session, `agent-ra-${session}.jsonl`); + writeFileSync(f, lines.join('\n') + '\n'); + // Backdated below the ledger's prior-window close (nowMs+1500): written + // AFTER ledger(), a >1.5s CI stall would otherwise fence prior-session + // fixtures out via the `until` clamp. + const past = new Date(Date.now() - 10_000); + utimesSync(f, past, past); } it('credits the prior attempt before this session has launched anything', () => { @@ -390,9 +393,11 @@ describe('the real reader on a resumed run — prior-session auditors count', () // auditor — a fail-open on the gate's own withhold-only invariant. ledger('S1'); auditorTranscript('S1', LAYERS); - // Backdate the record to before the plan's epoch (the suite pins the - // plan at 2020-01-01, so the dead attempt's leftovers predate it). - const past = new Date(2019, 0, 1); + // Backdate the record INSIDE the would-be slack window: one second + // before the plan's mtime. The strict fence excludes it; a slacked fence + // (runEpochMs = mtime − 2000) would re-admit it — the consolidation a + // refactor reaches for, which a year-apart fixture cannot see. + const past = new Date(new Date(2020, 0, 1).getTime() - 1000); const rec = join( promptRecordDir(plan), `${encodeURIComponent('reverse-audit')}.txt`, @@ -537,6 +542,80 @@ describe('the real reader on a resumed run — prior-session auditors count', () expect(out).toHaveLength(4); }); + it('refuses an identity MENTION that is not the identity line', () => { + // The anchor's strictness: a launch that merely CONTAINS the substring + // (a verifier told to coordinate with reverse-audit) must not match, or + // a bare-substring weakening of REVERSE_AUDIT_IDENTITY ships green. + ledger('S1'); + const key = 'verify'; + const brief = briefPath(plan, key); + const launch = + 'You are review agent `verify` — after the reverse-audit pass, rule.\n' + + `read_file(file_path="${brief}")\n` + + `read_file(file_path="${diff}", offset=0, limit=100)`; + mkdirSync(promptRecordDir(plan), { recursive: true }); + writeFileSync(brief, 'The verify brief.'); + recordPrompt(plan, key, launch); + const base = { + agentId: 'vm', + agentName: 'general-purpose', + sessionId: 'S1', + }; + const f = join(dir, 'subagents', 'S1', 'agent-vm.jsonl'); + writeFileSync( + f, + [ + JSON.stringify({ + ...base, + type: 'user', + message: { role: 'user', parts: [{ text: launch }] }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + text: LAYERS.map((id) => `Layer walked: ${id} — done.`).join( + '\n', + ), + }, + ], + }, + }), + ].join('\n') + '\n', + ); + auditorTranscript('S1', ['lexing', 'expansion']); + expect(layerAuditGate(plan, ENV()).unreviewed).toHaveLength(4); + }); + + it('refuses a delivered auditor with ZERO diff reads', () => { + // The diffToolCalls clause on a fixture that passes delivery: the parrot + // fails delivered() first, so the clause was deletable with the suite + // green. + ledger('S1'); + auditorTranscript('S1', LAYERS); + const f = join(dir, 'subagents', 'S1', 'agent-ra-S1.jsonl'); + const needle = JSON.stringify(diff).slice(1, -1); + const lines = readFileSync(f, 'utf8') + .trim() + .split('\n') + // Drop only the diff CALL/RESPONSE pair — the launch line also names + // the path, and removing it would erase the identity instead. + .filter( + (l) => + !( + l.includes(needle) && + (l.includes('"functionCall"') || l.includes('"functionResponse"')) + ), + ); + writeFileSync(f, lines.join('\n') + '\n'); + const past = new Date(Date.now() - 10_000); + utimesSync(f, past, past); + expect(layerAuditGate(plan, ENV()).unreviewed).toHaveLength(6); + }); + it('sees nothing from a prior session the ledger never recorded', () => { // A PARTIAL walk is the discriminating shape: were the un-ledgered // transcript visible, one identity-matched auditor covering two layers diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.ts index cc557856ad4..7debe86d6d6 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.ts @@ -122,6 +122,12 @@ function readReverseAuditReturns( const built = readRecordedPrompts(planPath, since); const delivered = (t: (typeof auditors)[number]): boolean => { for (const [key, prompt] of built) { + // Only reverse-audit records can deliver a reverse-audit receipt: + // `wasDeliveredVerbatim` allows additions, so a CONCATENATED launch + // (this role's block plus a sibling role's) verbatim-matches the + // sibling's record too — and the brief bar was then satisfiable by + // the sibling's brief, never this role's instructions. + if (!key.startsWith('reverse-audit')) continue; if (prompt.trim() === '') continue; if (!wasDeliveredVerbatim(t.launchPrompt, prompt)) continue; const needle = JSON.stringify(briefPath(planPath, key)); diff --git a/packages/cli/src/commands/review/lib/retirement.test.ts b/packages/cli/src/commands/review/lib/retirement.test.ts index 4efaa92e40e..c0af3bde51a 100644 --- a/packages/cli/src/commands/review/lib/retirement.test.ts +++ b/packages/cli/src/commands/review/lib/retirement.test.ts @@ -1890,10 +1890,12 @@ describe('scheduleReverseAuditRound — a resumed run reads the prior attempt', message: { role: 'model', parts: [{ text: DRY }] }, }), ]; - writeFileSync( - join(dir, 'subagents', session, `agent-${id}.jsonl`), - lines.join('\n') + '\n', - ); + const f = join(dir, 'subagents', session, `agent-${id}.jsonl`); + writeFileSync(f, lines.join('\n') + '\n'); + // Backdated below the ledger fixture's prior-window close: a CI stall + // after ledger() would otherwise fence these out via the until clamp. + const past = new Date(Date.now() - 10_000); + utimesSync(f, past, past); } function ledger(...ids: string[]): void { diff --git a/packages/cli/src/commands/review/lib/retirement.ts b/packages/cli/src/commands/review/lib/retirement.ts index f188d214b6d..2a8a6cfe05f 100644 --- a/packages/cli/src/commands/review/lib/retirement.ts +++ b/packages/cli/src/commands/review/lib/retirement.ts @@ -413,16 +413,6 @@ function substantiveClause(clause: string): boolean { * this module lands on the audit side. `memo` keys on the pointer so the * pairing walk reads each round's list once, not once per record. */ -/** - * Did this transcript's agent successfully `read_file` the findings pointer - * its record's prompt names? True when the prompt names none. - */ -function readTheFindingsPointer(rec: AgentRecord, lines: string[]): boolean { - const pointer = findingsPointerOf(lines.join('\n')); - if (pointer === null) return true; - const needle = JSON.stringify(pointer); - return rec.successfulReadFileArgs.some((a) => a.includes(needle)); -} function findingsListFor( prompt: string, @@ -479,6 +469,24 @@ function stripLayerReceiptLines(finalText: string): string { return kept.join('\n'); } +/** + * Did this transcript's agent successfully `read_file` the findings pointer + * its record's prompt names? True when the prompt names none. + * + * Takes the POINTER, extracted once from the RAW prompt by the same call + * `findingsListFor` uses: extracting again from trim-normalized lines asked + * the same question under a different normalization, and trimming defeats + * the `^…$` anchors that exist to reject indented quotations. + */ +function readTheFindingsPointer( + rec: AgentRecord, + pointer: string | null, +): boolean { + if (pointer === null) return true; + const needle = JSON.stringify(pointer); + return rec.successfulReadFileArgs.some((a) => a.includes(needle)); +} + /** * Classify one auditor's return. * @@ -696,6 +704,7 @@ export function scheduleReverseAuditRound( lines: string[]; territory: Array<[number, number]>; findings: string; + pointer: string | null; }> = []; for (const [key, prompt] of built) { const m = RECORD_KEY_RE.exec(key); @@ -711,6 +720,7 @@ export function scheduleReverseAuditRound( lines: promptLines(prompt), territory: bakedRanges(prompt, diffPath), findings: findingsListFor(prompt, recordDir, findingsMemo), + pointer: findingsPointerOf(prompt), }); } @@ -772,9 +782,10 @@ export function scheduleReverseAuditRound( // skipped the read cannot have performed it, and two such receipts // would retire the chunk on a comparison nobody made. A prompt with no // pointer (the pre-#8597 shape, list folded in verbatim) has nothing - // to open and keeps the old bar. + // to open and keeps the old bar. The POINTER was extracted once from + // the RAW prompt by the same call `findingsListFor` uses. const readCompliant = unique.filter((t) => - readTheFindingsPointer(t, records[i].lines), + readTheFindingsPointer(t, records[i].pointer), ); const classifications = readCompliant.map((t) => classifyReturn(t, records[i].territory, records[i].findings), diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 25c7d5788a8..67626683a33 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -157,6 +157,40 @@ describe('appendRunSession / priorSessionIds', () => { expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); }); + it('drops a traversal id from the MARKER even with a valid fence', () => { + // The planted entry must fail ONLY the charset gate: with planMtimeMs + // valid, removing SESSION_ID_RE from readResumeMarker is what this pins. + recordResume(plan, envOf('S1')); + const raw = JSON.parse(readFileSync(resumeMarkerPath(plan), 'utf8')) as { + resumes: Array>; + }; + raw.resumes.push({ + sessionId: '../../etc', + atMs: Date.now(), + planMtimeMs: statSync(plan).mtimeMs, + }); + writeFileSync(resumeMarkerPath(plan), JSON.stringify(raw)); + expect(readResumeMarker(plan).resumes.map((r) => r.sessionId)).toEqual([ + 'S1', + ]); + }); + + it('does not write the marker at all for a charset-refused id', () => { + // The write-side gate, observed through the FILE: the read gate would + // mask its removal. + recordResume(plan, envOf('../evil')); + expect(existsSync(resumeMarkerPath(plan))).toBe(false); + }); + + it('authorizes a case-variant of the recorded resume session', () => { + // resumeAuthorized folds on the path key like every other comparison; a + // raw-string compare ships green on byte-identical fixtures and refuses + // the continuation on the first case-folding filesystem. + recordResume(plan, envOf('S2')); + appendRunSession(plan, envOf('S1')); + expect(priorSessionIds(plan, envOf('s2'))).toEqual(['S1']); + }); + it('drops a traversal-shaped id on READ even when the file carries it', () => { // The ledger file itself is inside the record dir the orchestrator can // reach; a hand-written entry must still fail the character-set gate. @@ -409,9 +443,18 @@ describe('the properties the threat model rests on', () => { recursive: true, }); const target = join(root, 'elsewhere.json'); + // A fully VALID entry behind the link: without the fence fields, the + // mandatory planMtimeMs clause dropped it even through a FOLLOWING read, + // and the node-type refusal was deletable with the test green. writeFileSync( target, - JSON.stringify([{ sessionId: 'X', atMs: Date.now() }]), + JSON.stringify([ + { + sessionId: 'X', + atMs: Date.now(), + planMtimeMs: statSync(plan).mtimeMs, + }, + ]), ); symlinkSync(target, runSessionsPath(plan)); authorize('S2'); @@ -457,7 +500,20 @@ describe('the properties the threat model rests on', () => { recursive: true, }); const pad = 'x'.repeat(256 * 1024 + 1); - writeFileSync(runSessionsPath(plan), JSON.stringify([{ pad }])); + // A VALID entry rides inside: with the byte guard deleted the parse + // succeeds and this entry survives — so only the guard can produce the + // empty read, and deleting it goes red instead of green. + writeFileSync( + runSessionsPath(plan), + JSON.stringify([ + { + sessionId: 'S1', + atMs: Date.now(), + planMtimeMs: statSync(plan).mtimeMs, + }, + { pad }, + ]), + ); authorize('S2'); expect(priorSessionIds(plan, envOf('S2'))).toEqual([]); expect(sessionEntryCount(plan)).toBe(0); @@ -534,8 +590,14 @@ describe('the properties the threat model rests on', () => { // Win32 trailing-dot stripping, the harness sanitizer's '.' → '_'), so // they are one session everywhere or an alias reads as a second session // wearing the first one's evidence. + // Backdate the plan so every offset sits inside BOTH halves of the + // window — at now-based stamps, base+3000 exceeded the now+2000 future + // ceiling and the S2_ entry was dropped by the fence before the dedup + // this test pins ever saw it. + const past = new Date(Date.now() - 300_000); + utimesSync(plan, past, past); const mtime = statSync(plan).mtimeMs; - const base = Date.now(); + const base = Math.floor(mtime); mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { recursive: true, }); @@ -620,10 +682,12 @@ describe('the properties the threat model rests on', () => { // at zero". recordResume(plan, envOf('S1')); expect(readResumeMarker(plan).resumes).toHaveLength(1); - // A fresh capture rewrites the plan (mtime moves — pushed past the - // tolerance explicitly, since two writes can land inside 1ms). + // A fresh capture rewrites the plan. Nudged 3s forward — outside the + // 1ms tolerance AND small enough that the entry's atMs stays inside the + // new epoch window (a +60s jump put the epoch past atMs, so the entry + // died on the window fence and the plan-mtime clause was deletable). writeFileSync(plan, JSON.stringify({ diffLines: 2, chunks: [] })); - const later = new Date(Date.now() + 60_000); + const later = new Date(Date.now() + 3_000); utimesSync(plan, later, later); expect(readResumeMarker(plan).resumes).toEqual([]); }); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index d106be10ec8..ef28ecf378e 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -412,8 +412,10 @@ function resumeAuthorized( * ledger clamps a prior session's chat usage to it: an interrupted session * whose CLI kept being used for unrelated turns afterwards would otherwise * bill that activity as review cost, the mirror of the omission the ledger - * exists to prevent. `null` when nothing followed it (it is the newest prior - * entry and the current session's own start is not recorded here). + * exists to prevent. `null` only when this session has no ledger entry of + * its own to close the last prior's window with — in the normal flow + * `fetch-pr` appends unconditionally, so the newest prior is clamped to + * THIS session's start. */ export function priorSessionEntries( planPath: string, diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index 2730e75ac6e..7d1df95940b 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -302,7 +302,15 @@ describe('readRunTranscripts — the run across its sessions', () => { function priorFile(session: string, name: string, contents: string): void { mkdirSync(join(dir, 'subagents', session), { recursive: true }); - writeFileSync(join(dir, 'subagents', session, name), contents); + const f = join(dir, 'subagents', session, name); + writeFileSync(f, contents); + // Backdated: the ledger fixture closes the prior window ~1.5s after its + // own `Date.now()`, and these files are written AFTERWARD — a CI stall + // between the two would fence them out via the `until` clamp and flake + // every positive test. Ten seconds back keeps them inside any window a + // stall can produce, and still after the backdated plan. + const past = new Date(Date.now() - 10_000); + utimesSync(f, past, past); } it('unions prior-session transcripts, marked fromPriorSession', () => { @@ -441,6 +449,30 @@ describe('readRunTranscripts — the run across its sessions', () => { expect(recs.map((r) => r.agentId)).toEqual(['a1']); }); + it('finds a dotted PRIOR session under its sanitized directory', () => { + // The ledger charset admits dots; the harness writes `subagents/S_0dot`. + // Under a raw join the lookup ENOENTs and every reader silently sees + // nothing — the current-session side is pinned, this is the prior side. + const plan = planWithLedger('S.0dot', 'S1'); + mkdirSync(join(dir, 'subagents', 'S_0dot'), { recursive: true }); + const f = join(dir, 'subagents', 'S_0dot', 'agent-ap.jsonl'); + writeFileSync( + f, + JSON.stringify({ + agentId: 'ap', + agentName: 'general-purpose', + sessionId: 'S.0dot', + type: 'user', + message: { role: 'user', parts: [{ text: 'launch ap' }] }, + }) + '\n', + ); + const past = new Date(Date.now() - 10_000); + utimesSync(f, past, past); + file('agent-a1.jsonl', transcript('a1')); + const recs = readRunTranscripts(plan, undefined, ENV); + expect(recs.map((r) => r.agentId).sort()).toEqual(['a1', 'ap']); + }); + it('lists each prior session once, and only those that exist', () => { const plan = planWithLedger('S0', 'S0', 'S1'); // A prior session that exists on disk: the accessor skips a ledgered id diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index 244df8b70f8..4b0e7f458fb 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -16,6 +16,7 @@ import { symlinkSync, utimesSync, writeFileSync, + renameSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -1054,9 +1055,15 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( expect(Math.abs(statSync(planPath).mtimeMs - mtimeBefore)).toBeLessThan( 1, ); - // The rewrite itself still happened: the plan carries no context here, - // but the write path ran (content is re-serialized). - expect(() => JSON.parse(readFileSync(planPath, 'utf8'))).not.toThrow(); + // The rewrite itself still happened — asserted on CONTENT, not just + // parseability: a regression that skips the write when content changed + // passes both mtime assertions. + expect( + (JSON.parse(readFileSync(planPath, 'utf8')) as Record)[ + 'repositoryContext' + ], + ).toBeUndefined(); + expect(readFileSync(planPath, 'utf8')).toContain('files'); } finally { rmSync(root, { recursive: true, force: true }); } @@ -1102,6 +1109,38 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( } }); + it('aborts on a rename-replacement even with the mtime restored', () => { + // The inode disjunct: an in-place rewrite fires the mtime clause, so a + // swap that RESTORES the mtime — the checkpoint-faking shape — is the + // only thing this half catches, and no test exercised it. + const root = mkdtempSync(join(tmpdir(), 'repo-context-ino-')); + try { + const worktree = join(root, 'wt'); + mkdirSync(worktree, { recursive: true }); + const planPath = planAt(root, { files: [{ path: 'src/a.ts' }] }); + const before = statSync(planPath); + const racer: RepositoryContextProvider = { + provide() { + // Replace the FILE (new inode), then put the old mtime back. + const tmp = join(root, 'swap.json'); + writeFileSync(tmp, readFileSync(planPath, 'utf8')); + rmSync(planPath); + renameSync(tmp, planPath); + utimesSync(planPath, before.atime, before.mtime); + return null; + }, + }; + expect(() => + runRepoContext( + { plan: planPath, worktree, out: join(root, 'ctx.json') }, + [racer], + ), + ).toThrow(/changed while repository context was being computed/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('aborts when the plan changed while providers were running', () => { // The plan path is shared per PR and providers take real time: a // concurrent capture can replace the file mid-computation, and this run From ea2d3960c68ecab260ec7067480cddc0cd06b5ee Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 16:04:03 +0800 Subject: [PATCH 20/21] =?UTF-8?q?fix(review):=20round-9=20blockers=20?= =?UTF-8?q?=E2=80=94=20single-read=20ledger=20writers,=20lifecycle-aware?= =?UTF-8?q?=20returns,=20floor=20narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-ledger: the clobber guards decided from a SECOND read, so a transient fault clearing between the two reads let an append rewrite the whole ledger from the empty fallback; all three writers now classify the occupant once and decide everything from that one read, with the plan stat shared between the fence and the new entry. Plant shapes that can never be legitimate state — a directory (EISDIR on every rename, swallowed forever), an oversize regular file (frozen as "state to preserve" for the life of the plan) — are healed by the writers instead of freezing recording. The entry cap keeps the NEWEST end, so a backdated flood of distinct ids truncates itself rather than evicting the genuine entries and laundering the eviction on the next append. transcripts: `returned` now consults the harness's own lifecycle record — the `agent-.meta.json` sidecar — so an agent killed after a text flush (which ends identically to a completed one in transcript content) no longer certifies; thought parts are excluded from the return text, so a thinking-mode agent killed between ROUND_TEXT and its tool calls does not hand its internal reasoning downstream as a verdict; and a transcript whose records carry two different session stamps is rejected whole — the grafted-tail shape that defeated the first-stamp ownership check. coverage: `currentDigestKeys` dates a findings-file-less key by its always-present prompt record instead of dropping it, closing the mirror case where the CURRENT digest's inlined-fallback keys were dropped and a previous round's verifier vouched for a list nobody opened; the same narrowing now applies to the Step 5 reverse-audit floor; and the Uncoverable supersession guard excludes records that themselves declare the same chunk, so two honest declarers no longer annihilate each other into a permanently unreviewable `missingChunks` loop. layer-audit-gate / retirement: both readers now require `returned` before consuming receipts — a died-mid-flight auditor's narration can carry receipt forms — and the gate's `delivered()` refuses a transcript that verbatim-matches more than one reverse-audit record (the concatenated-launch shape whose union territory corroborated layers no walk touched). Retirement's findings-read floor moves INTO the dry branch of `classifyReturn`, so a filed yield from an auditor that skipped the list read keeps its chunk hot instead of vanishing before classification. repo-context: the enriched plan is committed by a temp file that is stamped with the anchor's times BEFORE the rename and identity-checked against the anchor immediately before it — there is no longer an instant where the plan path carries an advanced epoch (the kill-window that permanently orphaned same-run evidence), and a concurrent capture landing during the read+compare+write window is refused instead of silently overwritten. Every fix carries a probe pinned by mutation: reverting each guard in isolation reddens at least one new test. --- .../commands/review/check-coverage.test.ts | 85 +++++++ .../cli/src/commands/review/lib/coverage.ts | 67 +++-- .../review/lib/layer-audit-gate.test.ts | 59 +++++ .../commands/review/lib/layer-audit-gate.ts | 44 ++-- .../src/commands/review/lib/prompt-record.ts | 5 + .../commands/review/lib/retirement.test.ts | 156 ++++++++++++ .../cli/src/commands/review/lib/retirement.ts | 45 +++- .../review/lib/run-ledger.race.test.ts | 117 +++++++++ .../commands/review/lib/run-ledger.test.ts | 74 ++++++ .../cli/src/commands/review/lib/run-ledger.ts | 240 +++++++++++++----- .../commands/review/lib/transcripts.test.ts | 111 ++++++++ .../src/commands/review/lib/transcripts.ts | 68 ++++- .../src/commands/review/repo-context.test.ts | 66 ++++- .../cli/src/commands/review/repo-context.ts | 72 +++++- 14 files changed, 1076 insertions(+), 133 deletions(-) create mode 100644 packages/cli/src/commands/review/lib/run-ledger.race.test.ts diff --git a/packages/cli/src/commands/review/check-coverage.test.ts b/packages/cli/src/commands/review/check-coverage.test.ts index 6e626bcc464..649fd5dbf1b 100644 --- a/packages/cli/src/commands/review/check-coverage.test.ts +++ b/packages/cli/src/commands/review/check-coverage.test.ts @@ -1855,6 +1855,12 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', () `read_file(file_path="${brief}")\n` + `read_file(file_path="${DIFF}")`; writeFileSync(join(d, `${encodeURIComponent(key)}.txt`), prompt); + // A stale generation's record is a round old in production; the record + // file now DATES a pointerless key (so a current inlined-fallback + // generation survives the window), and an undated fixture would sit + // inside the current window by accident of being written just now. + const staleAt = new Date(Date.now() - 600_000); + utimesSync(join(d, `${encodeURIComponent(key)}.txt`), staleAt, staleAt); transcript('vstale', prompt, { calls: 2, opens: [brief] }); // The CURRENT digest: dated (findings file on disk), launched, its list // unread — the floor must come back owed. @@ -1879,6 +1885,61 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', () expect(r.unverifiedFindings).toBe(false); }); + it('an undatable CURRENT digest cannot be vouched for by the previous round', () => { + // The mirror of the stale-pointerless drop: when the CURRENT digest's + // findings writes fail (the documented inline fallback), its keys have + // no findings file. Dropped, the window kept the PREVIOUS round's dated + // cluster and the floor passed `ok` on an earlier list's verifier — + // certifying a verification that never happened. The prompt record now + // dates every built key, so the current generation stays in the window. + const p = plan(); + step45(p, 'reverse-audit'); + // Round 1: digest A, dated, fully compliant — and a round old. + step45(p, 'verify--oldA1111111', { findings: true }); + const old = new Date(Date.now() - 600_000); + utimesSync(findingsFilePath(p, 'verify--oldA1111111'), old, old); + utimesSync( + join( + promptRecordDir(p), + `${encodeURIComponent('verify--oldA1111111')}.txt`, + ), + old, + old, + ); + // Round 2: digest B, findings write failed (no file, no pointer), its + // verify shard never launched — the failure the floor exists to catch. + step45(p, 'verify--newB2222222', { launch: false }); + + const r = verificationGaps(p, { postsFindings: true }, ENV); + expect(r.unverifiedFindings).toBe(true); + }); + + it('the reverse-audit floor is narrowed to the current digest too', () => { + // Reverse keys accumulate per round/digest exactly like verify keys; + // ranging over all of them let a round-1 auditor's delivered receipt + // satisfy the floor after the findings list changed and the current + // round's audit was never delivered. + const p = plan(); + // Round 1: compliant, delivered — and a round old. + step45(p, 'reverse-audit--chunk-1--round-1--aaa1'); + const old = new Date(Date.now() - 600_000); + utimesSync( + join( + promptRecordDir(p), + `${encodeURIComponent('reverse-audit--chunk-1--round-1--aaa1')}.txt`, + ), + old, + old, + ); + // Round 3: built, never launched. + step45(p, 'reverse-audit--chunk-1--round-3--ccc3', { launch: false }); + + const r = verificationGaps(p, { postsFindings: false }, ENV); + expect(r.remediation.some((m) => m.startsWith('reverse audit:'))).toBe( + true, + ); + }); + it('passes when both verify and reverse audit ran on a review with findings', () => { const p = plan(); step45(p, 'reverse-audit'); @@ -2515,6 +2576,30 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage' expect(r.recoveredAgents).toBe(0); }); + it('two honest returned declarers do not annihilate each other', () => { + // Both clear `chunkSatisfied`'s bar (returned, verbatim launch, diff + // read), so each superseded the other: both declarations vanished, no + // record covered the chunk, and it landed in `missingChunks` — whose + // remediation relaunches an agent that re-declares, reproducing the + // identical report forever. Supersession now excludes records that + // themselves declare the same chunk. + const p = plan(); + transcript('a1', good(1), { + calls: 2, + text: 'Uncoverable: chunk 1 — line exceeds the read limit', + }); + transcript('a1b', good(1), { + calls: 2, + text: 'Uncoverable: chunk 1 — line exceeds the read limit', + }); + transcript('a2', good(2), { calls: 2 }); + + const r = coverageFromTranscripts(p, ENV); + expect(r.uncoverableChunks).toEqual([1]); + expect(r.missingChunks).toEqual([]); + expect(r.coveredChunks).toEqual([2]); + }); + it('an unsuperseded declaration still caps, resumed or not', () => { const p = plan(); ledger(p, 'S0', 'S1'); diff --git a/packages/cli/src/commands/review/lib/coverage.ts b/packages/cli/src/commands/review/lib/coverage.ts index 8fa32063af9..f192589f5d0 100644 --- a/packages/cli/src/commands/review/lib/coverage.ts +++ b/packages/cli/src/commands/review/lib/coverage.ts @@ -65,6 +65,7 @@ import { briefPath, findingsPointerOf, findingsFilePath, + recordedPromptPath, } from './prompt-record.js'; import { requiredAgents, @@ -794,7 +795,20 @@ export function coverageFromTranscripts( // below (`for (const id of uncoverable) covered.delete(id)` is // post-loop and order-independent), so no compliant relaunch can ever // clear it and the verdict caps on lines this run demonstrably read. - if (!superseded(rec, chunk)) uncoverable.add(chunk); + // + // Narrowed to records that do not THEMSELVES declare this chunk: a + // returned declarer clears `chunkSatisfied`'s bar (verbatim launch, + // diff read), so two honest declarations otherwise annihilate each + // other — the chunk lands in `missingChunks`, whose remediation + // relaunches an agent that re-declares, forever. `gapsSuperseded` + // below excludes same-shape records for exactly this reason. + const redeclares = (r: AgentRecord): boolean => { + const ru = UNCOVERABLE_RE.exec(r.finalText); + return ru !== null && Number(ru[1]) === chunk; + }; + if (!chunkSatisfied(chunk, rec, (r) => !redeclares(r))) { + uncoverable.add(chunk); + } continue; } @@ -1477,12 +1491,16 @@ export interface VerificationReport { * different thing entirely: an agent still running, which the idle checks own. * * Every CERTIFYING gate goes through here — coverage and the Step 4/5 - * floor. Two run-scoped readers deliberately do not: the layer-audit - * corroboration and the retirement scheduler consume receipts, where an - * empty return already contributes nothing (`parseLayerReceipts('')` is - * empty and `classifyReturn('')` is `unknown`), so for them the filter would - * be a second copy of a refusal they already make — and both fail SAFE - * without it, over-owing rather than releasing. + * floor. Two run-scoped readers do not call this helper but enforce the + * same `returned` requirement at their own sites: the layer-audit + * corroboration filter and the retirement scheduler's classify pipeline. + * The earlier premise for exempting them — "an empty return already + * contributes nothing" — was true only of EMPTY returns: `returned === + * false` also covers non-empty narration followed by tool traffic, and a + * died-mid-flight auditor's receipt-shaped narration corroborated layers + * and retired chunks through both readers. Their filters are pinned in + * their own suites; this note exists so the next reader does not + * reintroduce the exemption on the old premise. */ function liveRecords(all: AgentRecord[]): AgentRecord[] { // `returned`, not merely non-empty: `finalText` keeps the last non-empty @@ -1623,6 +1641,16 @@ export function verificationGaps( * verdict stricter. */ const currentDigestKeys = (planPath: string, keys: string[]): string[] => { + // A key with no findings file is dated by its PROMPT RECORD instead — + // the `.txt` the builder always writes. Dropping undatable keys + // whenever any dated key existed failed in the mirror direction: when + // the CURRENT digest's findings write failed (the documented + // `writeFindingsFile` → inline fallback), its keys were the undatable + // ones, the window kept the PREVIOUS round's dated cluster, and the + // floor passed `ok` on an earlier list's verifier — certifying a + // verification that never happened. The record file dates every built + // key, so the current generation stays in the window and a genuinely + // stale pointerless generation still falls out of it. const dated: Array<{ key: string; mtimeMs: number }> = []; const undatable: string[] = []; for (const key of keys) { @@ -1632,19 +1660,18 @@ export function verificationGaps( mtimeMs: statSync(findingsFilePath(planPath, key)).mtimeMs, }); } catch { - undatable.push(key); + try { + dated.push({ + key, + mtimeMs: statSync(recordedPromptPath(planPath, key)).mtimeMs, + }); + } catch { + undatable.push(key); + } } } if (dated.length === 0) return keys; const newest = Math.max(...dated.map((d) => d.mtimeMs)); - // Undatable keys are DROPPED once any dated key exists. The earlier - // premise — "they cannot reach ok, so they only make the verdict - // stricter" — is false for the write-failure fallback: a key whose list - // was inlined has no findings file and no pointer, the findings-read - // floor is vacuously satisfied, and a stale digest's pointerless - // verifier could vouch for a list no verifier opened. With no dated key - // at all (every round inlined), the undatable set is the only evidence - // there is and stays. return dated .filter((d) => d.mtimeMs >= newest - DIGEST_WINDOW_MS) .map((d) => d.key); @@ -1677,7 +1704,13 @@ export function verificationGaps( const reverseKeys = [...built.keys()].filter( (k) => k === 'reverse-audit' || k.startsWith('reverse-audit--'), ); - const reverse = bestDelivery(reverseKeys); + // Narrowed to the current digest exactly like the verify floor below: + // reverse keys accumulate per round/digest the same way, and ranging over + // all of them let a round-1 auditor's delivered receipt satisfy the floor + // after the findings list changed and the current round's audit was never + // delivered — with the prior-session widening making that stale auditor + // reachable across attempts too. + const reverse = bestDelivery(currentDigestKeys(planPath, reverseKeys)); // A TIME-budget stop marker means the round builder refused the reverse // audit on the run's time budget. Exactly ONE gap shape is then by design: // `not-built` — the refusal writes no record, so an audit with no records diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts index b11940c7294..0e117c07a58 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.test.ts @@ -5,6 +5,7 @@ */ import { + appendFileSync, mkdtempSync, mkdirSync, readFileSync, @@ -385,6 +386,64 @@ describe('the real reader on a resumed run — prior-session auditors count', () expect(out).toHaveLength(6); }); + it('refuses receipts from an auditor that never RETURNED', () => { + // A died-mid-flight auditor's narration can carry every receipt form — + // the harness flushes text before the round's tool calls — and + // corroborating layers from it is the RELEASE direction, the one the + // gate's header rules out. Tool traffic after the receipts text is the + // died shape: `returned: false`. + ledger('S1'); + auditorTranscript('S1', LAYERS); + const f = join(dir, 'subagents', 'S1', 'agent-ra-S1.jsonl'); + const base = { + agentId: 'ra-S1', + agentName: 'general-purpose', + sessionId: 'S1', + }; + appendFileSync( + f, + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: diff, offset: 0, limit: 100 }, + }, + }, + ], + }, + }) + '\n', + ); + const past = new Date(Date.now() - 9_000); + utimesSync(f, past, past); + const out = layerAuditGate(plan, ENV()).unreviewed; + expect(out).toHaveLength(6); + }); + + it('a transcript matching TWO reverse-audit records delivers neither', () => { + // `wasDeliveredVerbatim` allows additions, so a launch CONCATENATING + // two reverse-audit blocks matches both records — and the territory + // check ranges over the launch-wide UNION of baked ranges, so a walk of + // one block's territory corroborated everything the other block owed. + // A transcript matching more than one record names no territory + // specifically and delivers none (retirement's injectivity rule). + ledger('S1'); + auditorTranscript('S1', LAYERS); + // A second reverse-audit record whose lines the SAME launch already + // verbatim-contains — the minimal concatenation shape. + recordPrompt( + plan, + 'reverse-audit--chunk-9', + `read_file(file_path="${diff}", offset=0, limit=100)`, + ); + const out = layerAuditGate(plan, ENV()).unreviewed; + expect(out).toHaveLength(6); + }); + it('refuses a STALE record a dead attempt left beside the plan', () => { // The records read as HISTORY must take the run-epoch fence, like the // sibling history reader in retirement: nothing clears the record dir, diff --git a/packages/cli/src/commands/review/lib/layer-audit-gate.ts b/packages/cli/src/commands/review/lib/layer-audit-gate.ts index 7debe86d6d6..740f845cdce 100644 --- a/packages/cli/src/commands/review/lib/layer-audit-gate.ts +++ b/packages/cli/src/commands/review/lib/layer-audit-gate.ts @@ -121,30 +121,42 @@ function readReverseAuditReturns( // direction of a dropped corroboration is withhold, never release. const built = readRecordedPrompts(planPath, since); const delivered = (t: (typeof auditors)[number]): boolean => { + // Only reverse-audit records can deliver a reverse-audit receipt: + // `wasDeliveredVerbatim` allows additions, so a CONCATENATED launch + // (this role's block plus a sibling role's) verbatim-matches the + // sibling's record too — and the brief bar was then satisfiable by + // the sibling's brief, never this role's instructions. The SAME + // concatenation axis exists within the role: a launch carrying two + // reverse-audit blocks matches both records, and the territory check + // below ranges over the launch-wide UNION of baked ranges — so a walk + // of one chunk's territory corroborated both chunks' layers. A + // transcript matching more than one record names no territory + // specifically and delivers none (retirement's injectivity rule). + let matched: string | null = null; for (const [key, prompt] of built) { - // Only reverse-audit records can deliver a reverse-audit receipt: - // `wasDeliveredVerbatim` allows additions, so a CONCATENATED launch - // (this role's block plus a sibling role's) verbatim-matches the - // sibling's record too — and the brief bar was then satisfiable by - // the sibling's brief, never this role's instructions. if (!key.startsWith('reverse-audit')) continue; if (prompt.trim() === '') continue; if (!wasDeliveredVerbatim(t.launchPrompt, prompt)) continue; - const needle = JSON.stringify(briefPath(planPath, key)); - // READ, not named: `successfulCallArgs` covers every successful - // tool, so a grep or listing whose args merely CONTAIN the brief - // path cleared this — an auditor that never opened its instructions - // supplied a receipt. Only a successful `read_file` of the exact - // brief is opening it. - if (t.successfulReadFileArgs.some((a) => a.includes(needle))) { - return true; - } + if (matched !== null) return false; + matched = key; } - return false; + if (matched === null) return false; + const needle = JSON.stringify(briefPath(planPath, matched)); + // READ, not named: `successfulCallArgs` covers every successful + // tool, so a grep or listing whose args merely CONTAIN the brief + // path cleared this — an auditor that never opened its instructions + // supplied a receipt. Only a successful `read_file` of the exact + // brief is opening it. + return t.successfulReadFileArgs.some((a) => a.includes(needle)); }; const corroborated = auditors .filter( (t) => + // RETURNED, like every certification consumer: a died-mid-flight + // auditor's narration can carry receipt forms followed by tool + // traffic, and corroborating layers from it is the RELEASE + // direction — the one direction this gate's header rules out. + t.returned && t.diffToolCalls > 0 && delivered(t) && openedTheTerritory( @@ -152,7 +164,7 @@ function readReverseAuditReturns( bakedRanges(t.launchPrompt, diffPath), ), ) - .map((t) => t.finalText ?? ''); + .map((t) => t.finalText); return { corroborated, identityMatched: auditors.length }; } catch { // Could not MEASURE — a missing transcript dir (readTranscripts throws diff --git a/packages/cli/src/commands/review/lib/prompt-record.ts b/packages/cli/src/commands/review/lib/prompt-record.ts index 4ec5d50768e..c7d6c649251 100644 --- a/packages/cli/src/commands/review/lib/prompt-record.ts +++ b/packages/cli/src/commands/review/lib/prompt-record.ts @@ -88,6 +88,11 @@ export function promptRecordDir(planPath: string): string { const fileFor = (key: string) => `${encodeURIComponent(key)}.txt`; /** Where this agent's brief lives — the file it is told to read first. */ +/** Where `recordPrompt` puts a key's launch prompt — the always-present record. */ +export function recordedPromptPath(planPath: string, key: string): string { + return join(promptRecordDir(planPath), fileFor(key)); +} + export function briefPath(planPath: string, key: string): string { return join(promptRecordDir(planPath), `${encodeURIComponent(key)}.brief.md`); } diff --git a/packages/cli/src/commands/review/lib/retirement.test.ts b/packages/cli/src/commands/review/lib/retirement.test.ts index c0af3bde51a..89c66914a01 100644 --- a/packages/cli/src/commands/review/lib/retirement.test.ts +++ b/packages/cli/src/commands/review/lib/retirement.test.ts @@ -1605,6 +1605,162 @@ describe('scheduleReverseAuditRound — the scheduler on its own', () => { expect(r3.due).toEqual([13]); }); + /** A transcript whose FINAL text is followed by more tool traffic — the + * died-mid-flight shape: `returned: false`, narration only. */ + function deadTranscript(launchPrompt: string, narration: string): void { + const id = `aud-dead-${++seq}`; + const base = { + agentId: id, + agentName: 'general-purpose', + sessionId: 'S1', + }; + const call = JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: diff, offset: 0, limit: 100 }, + }, + }, + ], + }, + }); + const result = JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'diff bytes' }, + }, + }, + ], + }, + }); + writeFileSync( + join(dir, 'subagents', 'S1', `agent-${id}.jsonl`), + [ + JSON.stringify({ + ...base, + type: 'user', + message: { role: 'user', parts: [{ text: launchPrompt }] }, + }), + call, + result, + JSON.stringify({ + ...base, + type: 'assistant', + message: { role: 'model', parts: [{ text: narration }] }, + }), + // The traffic AFTER the text is what makes it narration: the agent + // went on working and the process died mid-walk. + call, + result, + ].join('\n') + '\n', + ); + } + + it('a died-mid-flight narration carrying a receipt shape classifies nothing', () => { + // `finalText` keeps the last non-empty assistant text, narration + // included — an auditor that printed a receipt-shaped progress line and + // was killed mid-walk must not read `dry`. Two such corpses would + // retire the chunk on an audit that never finished. + deadTranscript(record(1, 13, 'chunk 13 round 1 territory walk'), DRY); + deadTranscript(record(2, 13, 'chunk 13 round 2 territory walk'), DRY); + + const r3 = schedule(3, [13]); + expect(r3.due).toEqual([13]); + expect(r3.converged).toBe(false); + }); + + it('a filed YIELD survives a skipped findings read — the bar gates dry only', () => { + // The findings-read bar exists so a no-issues receipt cannot certify a + // comparison nobody made. Applied BEFORE classification it also + // suppressed filed findings: round 2's yielder skipped the list read, + // its yield vanished, the compliant dry sibling carried the round, and + // the chunk retired WITH a live finding on it. + transcript(record(1, 13, 'chunk 13 round 1 territory walk'), DRY); + const findingsFile = writeFindingsFile( + plan, + 'reverse-audit--round-2--yield7', + '- **File:** src/pay.ts:42 — the double charge\n' + + '- **Severity:** Suggestion\n', + ); + const built = record( + 2, + 13, + `chunk 13 round 2 territory walk\n` + + `read_file(file_path="${findingsFile}")`, + ); + // The yielder, by hand: territory read, NO findings read, a new finding. + const id = `aud-yielder-${++seq}`; + const base = { + agentId: id, + agentName: 'general-purpose', + sessionId: 'S1', + }; + writeFileSync( + join(dir, 'subagents', 'S1', `agent-${id}.jsonl`), + [ + JSON.stringify({ + ...base, + type: 'user', + message: { role: 'user', parts: [{ text: built }] }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: diff, offset: 0, limit: 100 }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { output: 'diff bytes' }, + }, + }, + ], + }, + }), + JSON.stringify({ + ...base, + type: 'assistant', + message: { role: 'model', parts: [{ text: YIELD }] }, + }), + ].join('\n') + '\n', + ); + // The compliant dry sibling for the same record (the helper models the + // findings read automatically). + transcript(built, DRY); + + const r3 = schedule(3, [13]); + // yielded outranks dry: round 2 is hot and the chunk stays due. + expect(r3.due).toEqual([13]); + expect(r3.skipped).toEqual([]); + }); + it('quoting a WHOLE entry from the findings FILE is not a yield (post-#8597 shape)', () => { // Since #8597 the cumulative list rides a digest-named `.findings.md` // file the launch prompt points at, not the prompt itself. The echo diff --git a/packages/cli/src/commands/review/lib/retirement.ts b/packages/cli/src/commands/review/lib/retirement.ts index 2a8a6cfe05f..65c8e80be2b 100644 --- a/packages/cli/src/commands/review/lib/retirement.ts +++ b/packages/cli/src/commands/review/lib/retirement.ts @@ -73,12 +73,14 @@ export type AuditOutcome = 'yielded' | 'dry' | 'unknown'; export type CertificationFailure = | 'no matching transcript' | 'launch matched multiple records' + | 'auditor never returned' | 'no successful tool calls' | 'no read of the diff' | 'territory read missing' | 'receipt not matched' | 'receipt not alone' - | 'receipt clause not substantive'; + | 'receipt clause not substantive' + | 'findings list unread'; /** One transcript's classified return, with the failed bar when not dry. */ interface Classification { @@ -512,7 +514,15 @@ function classifyReturn( rec: AgentRecord, territory: Array<[number, number]>, findingsList: string, + findingsRead: boolean, ): Classification { + // RETURNED, before anything else — the yield branch included: `finalText` + // keeps the last non-empty assistant text, narration included, so a + // died-mid-flight auditor's flushed narration can carry a receipt shape + // or a quoted finding; neither is a return. + if (!rec.returned) { + return { outcome: 'unknown', failure: 'auditor never returned' }; + } const text = rec.finalText.trim(); if (SEVERITY_LINE_RE.test(text)) { // The cumulative list is on hand for this agent: since #8597 it rides @@ -597,6 +607,14 @@ function classifyReturn( return unknown('receipt clause not substantive'); if (!substantiveClause(judgedClause)) return unknown('receipt clause not substantive'); + // The DRY bar only, and last: the brief's whole method is the comparison + // against the cumulative findings list, and a no-issues receipt from an + // auditor that never opened the list certifies a comparison nobody made. + // A filed YIELD (above) needs no such gate — the finding proves the + // territory hot whatever else was skipped, and gating it before + // classification flipped a round from yielded to dry and retired a chunk + // with a live finding. + if (!findingsRead) return unknown('findings list unread'); return { outcome: 'dry', failure: null }; } @@ -776,19 +794,18 @@ export function scheduleReverseAuditRound( const failuresByRecord: CertificationFailure[][] = []; matchesByRecord.forEach((matches, i) => { const unique = matches.filter((t) => recordsPerTranscript.get(t) === 1); - // The auditor must have READ the cumulative findings list its prompt - // points at before its receipt can classify at all. The brief's whole - // method is the comparison against known findings; an auditor that - // skipped the read cannot have performed it, and two such receipts - // would retire the chunk on a comparison nobody made. A prompt with no - // pointer (the pre-#8597 shape, list folded in verbatim) has nothing - // to open and keeps the old bar. The POINTER was extracted once from - // the RAW prompt by the same call `findingsListFor` uses. - const readCompliant = unique.filter((t) => - readTheFindingsPointer(t, records[i].pointer), - ); - const classifications = readCompliant.map((t) => - classifyReturn(t, records[i].territory, records[i].findings), + const classifications = unique.map((t) => + // The findings-read fact rides INTO the classification and gates only + // the dry branch there: applied out here as a filter it also + // suppressed filed YIELDS, flipping a round to dry and retiring a + // chunk that had a live finding. The POINTER was extracted once from + // the RAW prompt by the same call `findingsListFor` uses. + classifyReturn( + t, + records[i].territory, + records[i].findings, + readTheFindingsPointer(t, records[i].pointer), + ), ); classificationsByRecord.push(classifications); if (classifications.some((c) => c.outcome === 'dry')) { diff --git a/packages/cli/src/commands/review/lib/run-ledger.race.test.ts b/packages/cli/src/commands/review/lib/run-ledger.race.test.ts new file mode 100644 index 00000000000..81f678a169d --- /dev/null +++ b/packages/cli/src/commands/review/lib/run-ledger.race.test.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// The single-read property of the ledger writers, pinned by fault injection. +// +// The clobber guard used to decide from a SECOND read: "did the read fail?" +// asked once to build `entries`, then again to decide whether a present file +// was preserved. A transient fault (EMFILE, an AV scanner's EPERM) that +// cleared between the two reads defeated the guard — the first read failed, +// the guard's read succeeded, and the append rewrote the whole ledger from +// the empty fallback, erasing every recorded session. These probes live in +// their own file because they inject faults; every other run-ledger test +// runs against the real filesystem. +// +// `node:fs` is a sealed ESM namespace under the runner (vi.mock does not +// reach the module under test and vi.spyOn cannot redefine the export), so +// the faults go through the module's own `ledgerIoForTests` seam. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + appendRunSession, + ledgerIoForTests, + readResumeMarker, + recordResume, + resumeMarkerPath, + runSessionsPath, + sessionEntryCount, +} from './run-ledger.js'; + +let root: string; +let plan: string; + +const envOf = (sessionId: string): NodeJS.ProcessEnv => ({ + QWEN_CODE_PROJECT_DIR: root, + QWEN_CODE_SESSION_ID: sessionId, +}); + +const transient = (code: string): Error => + Object.assign(new Error(code), { code }); + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'run-ledger-race-'))); + plan = join(root, 'qwen-review-pr-7-fetch.json'); + writeFileSync(plan, JSON.stringify({ diffLines: 1, chunks: [] })); +}); +afterEach(() => { + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('single-read ledger writers', () => { + it('the append decides from exactly ONE read of the ledger', () => { + // Structural pin: with one read there is no between-reads window for a + // transient fault to clear in. A reintroduced guard read turns this red + // before any race does. + appendRunSession(plan, envOf('S1')); + const spy = vi.spyOn(ledgerIoForTests, 'readFileSync'); + appendRunSession(plan, envOf('S2')); + const ledgerReads = spy.mock.calls.filter( + (c) => c[0] === runSessionsPath(plan), + ); + expect(ledgerReads).toHaveLength(1); + expect(sessionEntryCount(plan)).toBe(2); + }); + + it('a transient fault on that one read refuses the append, never clobbers', () => { + appendRunSession(plan, envOf('S1')); + const before = readFileSync(runSessionsPath(plan), 'utf8'); + vi.spyOn(ledgerIoForTests, 'readFileSync').mockImplementationOnce(() => { + throw transient('EMFILE'); + }); + appendRunSession(plan, envOf('S2')); + // S2's append was skipped — one entry lost — but S1 survives: the + // failure direction is "skip one", never "erase all". + expect(readFileSync(runSessionsPath(plan), 'utf8')).toBe(before); + expect(sessionEntryCount(plan)).toBe(1); + }); + + it('a transient fault on the marker read refuses the record, never clobbers', () => { + recordResume(plan, envOf('S1')); + const before = readFileSync(resumeMarkerPath(plan), 'utf8'); + vi.spyOn(ledgerIoForTests, 'readFileSync').mockImplementationOnce(() => { + throw transient('EPERM'); + }); + recordResume(plan, envOf('S2')); + expect(readFileSync(resumeMarkerPath(plan), 'utf8')).toBe(before); + expect(readResumeMarker(plan).resumes.map((r) => r.sessionId)).toEqual([ + 'S1', + ]); + }); + + it('a transient plan-stat fault refuses the append, never clobbers', () => { + // The fence used to be computed from a SEPARATE stat inside the read + // path: a transient failure there dropped every entry while the + // writer's own stat succeeded, and the append rewrote the intact file + // from the empty list. One stat now serves the fence and the new entry. + appendRunSession(plan, envOf('S1')); + const before = readFileSync(runSessionsPath(plan), 'utf8'); + vi.spyOn(ledgerIoForTests, 'statSync').mockImplementationOnce(() => { + throw transient('EPERM'); + }); + appendRunSession(plan, envOf('S2')); + expect(readFileSync(runSessionsPath(plan), 'utf8')).toBe(before); + }); +}); diff --git a/packages/cli/src/commands/review/lib/run-ledger.test.ts b/packages/cli/src/commands/review/lib/run-ledger.test.ts index 67626683a33..3dae6de598a 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.test.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.test.ts @@ -842,3 +842,77 @@ describe('the properties the threat model rests on', () => { ]); }); }); + +describe('plant shapes — heal what cannot be legitimate, preserve what can', () => { + // The boundary the occupant classifier draws: a symlink, FIFO, directory + // or oversize regular file CANNOT be legitimate ledger state (this module + // never writes one), so preserving it hands a planted shape a permanent + // freeze of all recording — while a plausible regular file that merely + // failed to read may hold every recorded entry, and must be preserved. + + it('heals an OVERSIZE regular-file plant instead of freezing the ledger', () => { + // >256 KiB can never be legitimate (≤64 capped entries ≈ a few KB). + // Treated as a transient fault it froze every future append: nothing + // ever removed the file, so the ledger was dead for the life of the + // plan — the resume cap's backstop read 0 permanently. + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + writeFileSync(runSessionsPath(plan), 'x'.repeat(262 * 1024)); + appendRunSession(plan, envOf('S1')); + expect(sessionEntryCount(plan)).toBe(1); + expect(statSync(runSessionsPath(plan)).size).toBeLessThan(10_240); + expect(currentSessionEntry(plan, envOf('S1'))?.sessionId).toBe('S1'); + }); + + it('heals a DIRECTORY plant instead of dying on the rename forever', () => { + // A directory passes an is-regular-file guard in the negative direction + // and then fails the noFollow rename with EISDIR on every attempt — + // swallowed, so nothing was ever recorded and nothing ever self-healed. + mkdirSync(runSessionsPath(plan), { recursive: true }); + appendRunSession(plan, envOf('S1')); + expect(lstatSync(runSessionsPath(plan)).isFile()).toBe(true); + expect(sessionEntryCount(plan)).toBe(1); + }); + + it('heals both plant shapes at the resume marker too', () => { + mkdirSync(resumeMarkerPath(plan), { recursive: true }); + recordResume(plan, envOf('S1')); + expect(lstatSync(resumeMarkerPath(plan)).isFile()).toBe(true); + expect(readResumeMarker(plan).resumes.map((r) => r.sessionId)).toEqual([ + 'S1', + ]); + writeFileSync(resumeMarkerPath(plan), 'x'.repeat(262 * 1024)); + recordRestart(plan, 'head-moved'); + expect(readResumeMarker(plan).restarts.map((r) => r.reason)).toEqual([ + 'head-moved', + ]); + expect(statSync(resumeMarkerPath(plan)).size).toBeLessThan(10_240); + }); + + it('a backdated flood of 65 distinct plants cannot evict the genuine entry', () => { + // The cap keeps the NEWEST end. Keeping the oldest handed a flood of + // distinct backdated ids the whole cap: they sorted ahead, truncation + // evicted the genuine newest entries — the running session's own entry + // included, so its cost floor silently vanished — and the next append + // rewrote the file from the filtered list, laundering the eviction. + const past = new Date(Date.now() - 300_000); + utimesSync(plan, past, past); + const mtime = statSync(plan).mtimeMs; + const base = Math.floor(mtime); + const flood = Array.from({ length: 65 }, (_, i) => ({ + sessionId: `P${i}`, + atMs: base + i, + planMtimeMs: mtime, + })); + mkdirSync(join(root, 'qwen-review-pr-7-fetch-prompts'), { + recursive: true, + }); + writeFileSync(runSessionsPath(plan), JSON.stringify(flood)); + appendRunSession(plan, envOf('S-genuine'), base + 100_000); + expect(currentSessionEntry(plan, envOf('S-genuine'))?.sessionId).toBe( + 'S-genuine', + ); + expect(sessionEntryCount(plan)).toBe(64); + }); +}); diff --git a/packages/cli/src/commands/review/lib/run-ledger.ts b/packages/cli/src/commands/review/lib/run-ledger.ts index ef28ecf378e..fdb3202278c 100644 --- a/packages/cli/src/commands/review/lib/run-ledger.ts +++ b/packages/cli/src/commands/review/lib/run-ledger.ts @@ -30,7 +30,7 @@ // skill used to hold only in transcript memory: how many times this review has // resumed, and whether it already restarted once for head movement. -import { lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs'; +import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { atomicWriteFileSync, @@ -117,9 +117,18 @@ const PLAN_MTIME_TOLERANCE_MS = 1; * `utimesSync` round trip on every content-changing enrichment, and that round * trip costs a unit in the last place. See that constant. */ +/** + * The one indirection the fault-injection probes need. `node:fs` arrives as + * a sealed ESM namespace under the test runner, so a transient EMFILE/EPERM + * — the fault class the single-read design exists to survive — cannot be + * injected by mocking the module. Same idea as `contained-read`'s injectable + * read seam; production code never reassigns these. + */ +export const ledgerIoForTests = { readFileSync, statSync }; + function planMtimeMs(planPath: string): number | null { try { - return statSync(planPath).mtimeMs; + return ledgerIoForTests.statSync(planPath).mtimeMs; } catch { return null; } @@ -153,51 +162,104 @@ const MAX_LEDGER_BYTES = 256 * 1024; const MAX_LEDGER_ENTRIES = 64; /** - * Is there a REGULAR file at `path` that `readLedgerFile` refused? The - * clobber guard keys on this, not on bare existence: a planted symlink or - * FIFO is not previous state to preserve — the `noFollow` atomic write - * self-heals over it, and that behaviour is pinned — while a real file that - * failed to read (EMFILE, an AV scanner's EPERM) holds every previously - * recorded entry, and rewriting from the empty fallback would erase them. + * What occupies a ledger path, classified in ONE pass so a writer can make + * its whole decision from a single read. The clobber guard used to ask two + * separate questions ("did the read fail?" then "is a regular file there?"), + * and a transient fault that cleared between them defeated the guard: the + * first read failed, the second succeeded, and the append rewrote the whole + * file from the empty fallback — erasing every recorded session. + * + * - `ok` — a bounded regular file whose bytes are in hand. + * - `absent` — nothing there; an empty ledger is the ordinary first-write + * state. + * - `plant` — an occupant that CANNOT be legitimate state: a symlink or + * FIFO (this module never writes one), a directory (ditto — + * and the noFollow rename would fail EISDIR on it forever, + * silently killing every future append), or a regular file + * over `MAX_LEDGER_BYTES` (a legitimate ledger is ≤64 capped + * entries, a few KB; refusing to touch an oversize plant + * would freeze the ledger for the life of the plan). Writers + * heal these; readers see no entries. + * - `refused` — a present, plausible regular file whose read failed + * (EMFILE, an AV scanner's EPERM). It holds every previously + * recorded entry, so writers must preserve it: skipping one + * append loses one entry, a rewrite loses them all. */ -function unreadableRegularLedger(path: string): boolean { +type LedgerOccupant = + | { kind: 'ok'; text: string } + | { kind: 'absent' } + | { kind: 'plant'; shape: 'directory' | 'special' | 'oversize' } + | { kind: 'refused' }; + +function ledgerOccupant(path: string): LedgerOccupant { + let st; try { - return lstatSync(path).isFile(); + st = lstatSync(path); } catch { - return false; + return { kind: 'absent' }; } -} - -function readLedgerFile(path: string): string | null { + if (st.isDirectory()) return { kind: 'plant', shape: 'directory' }; + // A symlink would redirect the read and a FIFO would block it forever — a + // hang, not an error, in a command a review waits on. + if (!st.isFile()) return { kind: 'plant', shape: 'special' }; + // Bounded before the read: these files are bookkeeping (a handful of + // small entries), and a planted multi-gigabyte one would otherwise stall + // or exhaust every command that touches them. + if (st.size > MAX_LEDGER_BYTES) return { kind: 'plant', shape: 'oversize' }; try { - const st = lstatSync(path); - // Not a regular file: a symlink would redirect the read and a FIFO would - // block it forever — a hang, not an error, in a command a review waits on. - if (!st.isFile()) return null; - // Bounded before the read: these files are bookkeeping (a handful of - // small entries), and a planted multi-gigabyte one would otherwise stall - // or exhaust every command that touches them. - if (st.size > MAX_LEDGER_BYTES) return null; - return readFileSync(path, 'utf8'); + return { kind: 'ok', text: ledgerIoForTests.readFileSync(path, 'utf8') }; } catch { - return null; + return { kind: 'refused' }; + } +} + +/** + * Clear a planted occupant so the atomic write can land. The noFollow + * rename already replaces a symlink, FIFO or regular file; only a DIRECTORY + * survives it (EISDIR on every attempt), so only a directory needs removing. + */ +function healPlant(path: string, occ: LedgerOccupant): void { + if (occ.kind === 'plant' && occ.shape === 'directory') { + rmSync(path, { recursive: true, force: true }); } } +function readLedgerFile(path: string): string | null { + const occ = ledgerOccupant(path); + return occ.kind === 'ok' ? occ.text : null; +} + /** * This run's session entries, oldest first. Unreadable or malformed → empty: * the failure direction is "earlier evidence invisible", which coverage answers * by requiring the work again — never the reverse. */ function readSessions(planPath: string): SessionEntry[] { + return parseSessions( + readLedgerFile(runSessionsPath(planPath)), + planPath, + planMtimeMs(planPath), + ); +} + +/** + * Parse and fence ledger text that has already been read. The plan mtime is + * an ARGUMENT, not a fresh stat: the writers pass the same value they stamp + * into the new entry, so a transient stat fault cannot make the fence drop + * every existing entry while the writer's own stat succeeds — which would + * hand the append an empty list to rewrite the intact file from. + */ +function parseSessions( + raw: string | null, + planPath: string, + planMtime: number | null, +): SessionEntry[] { try { - const raw = readLedgerFile(runSessionsPath(planPath)); if (raw === null) return []; const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; const epoch = runEpochMs(planPath); const ceiling = runCeilingMs(); - const planMtime = planMtimeMs(planPath); // Validate FIRST, cap the survivors: sliced raw, 64 malformed entries at // the front consume the whole cap and hide every real one behind them — // `sessionEntryCount` then reads 0 and the resume cap resets, which is @@ -248,14 +310,22 @@ function readSessions(planPath: string): SessionEntry[] { // everything outside [A-Za-z0-9_-] to '_'. Folding on the raw id left // every one of those aliases open as a second identity. const seen = new Set(); - return capped - .slice() - .sort((x, y) => x.atMs - y.atMs) - .filter((e) => { - const k = sessionPathKey(e.sessionId); - return seen.has(k) ? false : (seen.add(k), true); - }) - .slice(0, MAX_LEDGER_ENTRIES); + return ( + capped + .slice() + .sort((x, y) => x.atMs - y.atMs) + .filter((e) => { + const k = sessionPathKey(e.sessionId); + return seen.has(k) ? false : (seen.add(k), true); + }) + // Keep the NEWEST end. Keeping the oldest handed a flood of distinct + // BACKDATED plants the whole cap: they sorted ahead of the genuine + // entries, the truncation evicted the genuine newest end — the running + // session's own entry included — and the next append rewrote the file + // from the filtered list, laundering the eviction permanently. A + // backdated flood now truncates itself. + .slice(-MAX_LEDGER_ENTRIES) + ); } catch { return []; } @@ -275,12 +345,6 @@ export function appendRunSession( try { const id = env['QWEN_CODE_SESSION_ID']?.trim(); if (!id || !SESSION_ID_RE.test(id)) return; - const entries = readSessions(planPath); - // Same equivalence as the read side: a pre-planted case- or alias-variant - // otherwise passes this check, and first-write-wins hands it the - // session's identity. - if (entries.some((e) => sessionPathKey(e.sessionId) === sessionPathKey(id))) - return; const mtime = planMtimeMs(planPath); // No plan mtime, no entry. `readSessions` hard-requires the field — an // entry that cannot say which plan it saw is dropped on every read, and @@ -289,22 +353,33 @@ export function appendRunSession( // that silently loses the id. Refusing up front is honest and identical // in effect, minus the false success. if (mtime === null) return; - // A ledger that EXISTS but could not be read is not an empty ledger: - // this append rewrites the whole file from what it read, so proceeding - // on a transient fault (EMFILE, an AV scanner's EPERM) would clobber - // every previously recorded entry — erasing attempt 1's address exactly - // when a resume needs it. Skipping the append loses one entry; the - // clobber loses them all. - if ( - readLedgerFile(runSessionsPath(planPath)) === null && - unreadableRegularLedger(runSessionsPath(planPath)) - ) { + const path = runSessionsPath(planPath); + // ONE read decides everything below. This append rewrites the whole file + // from what it read, so a ledger that EXISTS but could not be read must + // refuse the append: proceeding on a transient fault (EMFILE, an AV + // scanner's EPERM) would clobber every previously recorded entry — + // erasing attempt 1's address exactly when a resume needs it. Skipping + // the append loses one entry; the clobber loses them all. Deciding from + // a SECOND read opened a race: a fault clearing between the two reads + // made the guard see a healthy file while `entries` held the empty + // fallback. + const occ = ledgerOccupant(path); + if (occ.kind === 'refused') return; + const entries = parseSessions( + occ.kind === 'ok' ? occ.text : null, + planPath, + mtime, + ); + // Same equivalence as the read side: a pre-planted case- or alias-variant + // otherwise passes this check, and first-write-wins hands it the + // session's identity. + if (entries.some((e) => sessionPathKey(e.sessionId) === sessionPathKey(id))) return; - } + healPlant(path, occ); entries.push({ sessionId: id, atMs: nowMs, planMtimeMs: mtime }); const dir = promptRecordDir(planPath); mkdirSync(dir, { recursive: true }); - atomicWriteFileSync(runSessionsPath(planPath), JSON.stringify(entries), { + atomicWriteFileSync(path, JSON.stringify(entries), { noFollow: true, }); } catch { @@ -487,8 +562,26 @@ export function resumeMarkerPath(planPath: string): string { * abuse is the session ledger's entry count and the workflow's MAX_ATTEMPTS). */ export function readResumeMarker(planPath: string): ResumeMarker { + return parseMarker( + readLedgerFile(resumeMarkerPath(planPath)), + planPath, + planMtimeMs(planPath), + ); +} + +/** + * Parse and fence marker text that has already been read. Like + * `parseSessions`, the plan mtime is an argument: the writers pass the value + * they stamp into the new entry, so one stat serves the fence and the entry + * both, and a transient stat fault inside a second stat cannot fence-drop + * every recorded resume while the writer proceeds to rewrite the file. + */ +function parseMarker( + text: string | null, + planPath: string, + planMtime: number | null, +): ResumeMarker { try { - const text = readLedgerFile(resumeMarkerPath(planPath)); if (text === null) return emptyMarker(); const parsed = JSON.parse(text) as unknown; if ( @@ -500,7 +593,6 @@ export function readResumeMarker(planPath: string): ResumeMarker { } const epoch = runEpochMs(planPath); const ceiling = runCeilingMs(); - const planMtime = planMtimeMs(planPath); const raw = parsed as ResumeMarker; const seenResume = new Set(); const seenRestart = new Set(); @@ -591,21 +683,26 @@ export function recordResume( // Same refusal as the session ledger: an entry that cannot say which plan // it saw is dropped on every read, so writing it would be a dead write. if (mtime === null) return; - if ( - readLedgerFile(resumeMarkerPath(planPath)) === null && - unreadableRegularLedger(resumeMarkerPath(planPath)) - ) { - // Same clobber guard as the session ledger: an unreadable-but-present - // marker must not be rewritten from the empty default. - return; - } - const marker = readResumeMarker(planPath); + // Same single-read decision as `appendRunSession`: the guard and the + // parse consume ONE read, so a transient fault clearing between two reads + // cannot make the guard see a healthy marker while the parse holds the + // empty fallback — which `writeMarker` would then commit, erasing every + // recorded resume and restart. + const markerPath = resumeMarkerPath(planPath); + const occ = ledgerOccupant(markerPath); + if (occ.kind === 'refused') return; + const marker = parseMarker( + occ.kind === 'ok' ? occ.text : null, + planPath, + mtime, + ); if ( marker.resumes.some( (r) => sessionPathKey(r.sessionId) === sessionPathKey(id), ) ) return; + healPlant(markerPath, occ); marker.resumes.push({ sessionId: id, atMs: nowMs, planMtimeMs: mtime }); writeMarker(planPath, marker); } @@ -622,14 +719,17 @@ export function recordRestart( ): void { const mtime = planMtimeMs(planPath); if (mtime === null) return; - if ( - readLedgerFile(resumeMarkerPath(planPath)) === null && - unreadableRegularLedger(resumeMarkerPath(planPath)) - ) { - return; - } - const marker = readResumeMarker(planPath); + // Single-read decision; see `recordResume`. + const markerPath = resumeMarkerPath(planPath); + const occ = ledgerOccupant(markerPath); + if (occ.kind === 'refused') return; + const marker = parseMarker( + occ.kind === 'ok' ? occ.text : null, + planPath, + mtime, + ); if (marker.restarts.some((r) => r.reason === reason)) return; + healPlant(markerPath, occ); marker.restarts.push({ atMs: nowMs, reason, planMtimeMs: mtime }); writeMarker(planPath, marker); } diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index 7d1df95940b..ace5d152da6 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -619,3 +619,114 @@ describe('readRunTranscripts — containment and fault handling', () => { ).toThrow(TranscriptsUnavailableError); }); }); + +describe('the incomplete-transcript shapes the resume path reads', () => { + const user = (text: string) => + JSON.stringify({ + agentId: 'a1', + agentName: 'general-purpose', + sessionId: 'S1', + type: 'user', + message: { role: 'user', parts: [{ text }] }, + }); + const assistant = (parts: unknown[]) => + JSON.stringify({ + agentId: 'a1', + sessionId: 'S1', + type: 'assistant', + message: { role: 'assistant', parts }, + }); + + it('a non-terminal sidecar status makes the last text progress, not a return', () => { + // The harness appends ROUND_TEXT before the round's tool calls and + // writes NO terminal transcript record on completion, so an agent + // killed after a text flush ends IDENTICALLY to a completed one. The + // sidecar is the authoritative lifecycle record: a killed agent's + // persisted status stays 'running'. + file( + 'agent-a1.jsonl', + [user('go'), assistant([{ text: 'Verdict: clean.' }])].join('\n') + '\n', + ); + file( + 'agent-a1.meta.json', + JSON.stringify({ agentId: 'a1', status: 'running' }), + ); + const recs = readTranscripts(undefined, ENV); + expect(recs).toHaveLength(1); + expect(recs[0].finalText).toBe('Verdict: clean.'); + expect(recs[0].returned).toBe(false); + }); + + it('a completed sidecar (or none) leaves content inference standing', () => { + file( + 'agent-a1.jsonl', + [user('go'), assistant([{ text: 'Verdict: clean.' }])].join('\n') + '\n', + ); + file( + 'agent-a1.meta.json', + JSON.stringify({ agentId: 'a1', status: 'completed' }), + ); + expect(readTranscripts(undefined, ENV)[0].returned).toBe(true); + rmSync(join(dir, 'subagents', 'S1', 'agent-a1.meta.json')); + expect(readTranscripts(undefined, ENV)[0].returned).toBe(true); + }); + + it('a thought-only final record is not a return and never final text', () => { + // Thinking mode emits {text, thought: true} parts in ROUND_TEXT before + // the round's tool calls land as records — a kill between the two + // leaves a complete thought-only last record, and counting thoughts + // handed the agent's INTERNAL REASONING downstream as its verdict. + file( + 'agent-a1.jsonl', + [ + user('go'), + assistant([{ text: 'Real return.' }]), + assistant([ + { + text: 'Let me consider whether UNCOVERABLE applies…', + thought: true, + }, + ]), + ].join('\n') + '\n', + ); + const recs = readTranscripts(undefined, ENV); + expect(recs[0].finalText).toBe('Real return.'); + expect(recs[0].returned).toBe(true); + }); + + it('rejects a transcript whose records carry two different sessions', () => { + // A GRAFT: a forged head stamped with the directory's session (the + // launch prompt is deterministic per plan) spliced onto another + // session's genuine records. The ownership check keys on the first + // stamp, so per-line consistency has to be what rejects the file. + file( + 'agent-a1.jsonl', + [ + user('go'), + JSON.stringify({ + agentId: 'a1', + sessionId: 'S0', + type: 'assistant', + message: { role: 'assistant', parts: [{ text: 'grafted return' }] }, + }), + ].join('\n') + '\n', + ); + expect(readTranscripts(undefined, ENV)).toEqual([]); + }); + + it('keeps accepting unstamped lines beside stamped ones', () => { + // Older harness writes stamp nothing; absence is not a conflict. + file( + 'agent-a1.jsonl', + [ + user('go'), + JSON.stringify({ + agentId: 'a1', + type: 'assistant', + message: { role: 'assistant', parts: [{ text: 'ok' }] }, + }), + ].join('\n') + '\n', + ); + expect(readTranscripts(undefined, ENV)).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/commands/review/lib/transcripts.ts b/packages/cli/src/commands/review/lib/transcripts.ts index 80b7cd36e63..8351a255acb 100644 --- a/packages/cli/src/commands/review/lib/transcripts.ts +++ b/packages/cli/src/commands/review/lib/transcripts.ts @@ -184,6 +184,24 @@ export function textOf(rec: Record): string { .join(''); } +/** + * The record's RETURN text: text parts minus thinking. The runtime emits + * ROUND_TEXT carrying `{text, thought: true}` parts BEFORE the round's tool + * calls, so a thinking-mode agent killed between the two leaves a complete + * thought-only record as its last line — and counting thoughts made that + * internal reasoning the agent's `finalText` with `returned: true`. The + * runtime's own final-text extraction excludes thoughts; this mirrors it. + */ +function returnTextOf(rec: Record): string { + const msg = rec['message'] as { parts?: unknown } | undefined; + const parts = Array.isArray(msg?.parts) ? msg.parts : []; + return parts + .filter((p) => (p as { thought?: unknown }).thought !== true) + .map((p) => (p as { text?: unknown }).text) + .filter((t): t is string => typeof t === 'string') + .join(''); +} + /** * Did this tool result come back as an error? * @@ -253,6 +271,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { let agentId = ''; let agentName = ''; let recordedSession = ''; + let sessionConflict = false; let launchPrompt = ''; let finalText = ''; let toolTrafficAfterText = true; @@ -288,8 +307,19 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { if (!agentName && typeof rec['agentName'] === 'string') { agentName = rec['agentName']; } - if (!recordedSession && typeof rec['sessionId'] === 'string') { - recordedSession = rec['sessionId']; + if (typeof rec['sessionId'] === 'string' && rec['sessionId'] !== '') { + if (!recordedSession) { + recordedSession = rec['sessionId']; + } else if (rec['sessionId'] !== recordedSession) { + // A transcript whose head is stamped with one session and whose tail + // carries another is a GRAFT: a forged head (the launch prompt is + // deterministic per plan) spliced onto another session's genuine + // records would otherwise pass the ownership check, which keys on + // the first stamp. One file, one session — a conflict rejects the + // whole record. Unstamped lines stay accepted for older harness + // writes. + sessionConflict = true; + } } const type = rec['type']; @@ -354,7 +384,9 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { } if (type === 'assistant') { - const t = textOf(rec); + // Thoughts excluded: a thought-only round is not a return, and its + // reasoning must never be handed downstream as the agent's verdict. + const t = returnTextOf(rec); if (t) { finalText = t; toolTrafficAfterText = false; @@ -373,6 +405,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { } if (!agentId) return null; + if (sessionConflict) return null; let mtimeMs = 0; try { @@ -392,11 +425,38 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null { successfulCallArgs, successfulReadFileArgs, finalText, - returned: finalText.trim() !== '' && !toolTrafficAfterText, + returned: + finalText.trim() !== '' && !toolTrafficAfterText && !diedPerSidecar(file), mtimeMs, }; } +/** + * Does the harness's own lifecycle record say this agent never finished? + * + * The transcript alone cannot: the harness appends ROUND_TEXT before the + * round's tool calls and writes NO terminal record on completion, so an + * agent killed after a text flush and before its next record ends + * IDENTICALLY to a completed one. The `agent-.meta.json` sidecar is the + * authoritative signal — a killed agent's persisted status stays 'running'. + * Any persisted status other than 'completed' (running, paused, failed, + * cancelled) means the final text is where the agent WAS, not what it + * concluded. A missing or unreadable sidecar proves nothing and changes + * nothing: content inference stands, so older harness writes and fixtures + * without sidecars keep their meaning. + */ +function diedPerSidecar(transcriptFile: string): boolean { + const metaPath = transcriptFile.replace(/\.jsonl$/, '.meta.json'); + try { + const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as { + status?: unknown; + }; + return typeof meta.status === 'string' && meta.status !== 'completed'; + } catch { + return false; + } +} + /** * The session's subagent transcript files, one listing every reader shares. * diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index 4b0e7f458fb..1a900371bfa 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -7,6 +7,7 @@ import { chmodSync, linkSync, + readdirSync, mkdirSync, mkdtempSync, readFileSync, @@ -26,7 +27,11 @@ import { MAX_IDENTITY_BYTES, type RepositoryContextProvider, } from './lib/repository-context.js'; -import { repoContextCommand, runRepoContext } from './repo-context.js'; +import { + commitPlanPreservingEpoch, + repoContextCommand, + runRepoContext, +} from './repo-context.js'; import { stringifyPlanReport } from './lib/report.js'; import { isolateHostGitConfig } from './lib/test-utils.js'; import { @@ -1230,3 +1235,62 @@ describe('the plan mtime is the run epoch — enrichment must not advance it', ( } }); }); + +describe('commitPlanPreservingEpoch — the epoch never advances, even torn', () => { + let root: string; + let plan: string; + + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'commit-epoch-'))); + plan = join(root, 'plan.json'); + writeFileSync(plan, '{"old":true}'); + const past = new Date(Date.now() - 300_000); + utimesSync(plan, past, past); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it('the committed file carries the anchor epoch, atomically', () => { + // The temp file is stamped BEFORE the rename: there is no instant at + // which the plan path exists with an advanced mtime, so a kill at any + // point leaves either the old plan under the old epoch or the new plan + // under the old epoch — never the new plan under a new one. The + // separate write-then-restore pair had exactly that instant, and the + // skip-when-identical retry guard made it permanent. + const anchor = statSync(plan); + commitPlanPreservingEpoch(plan, '{"new":true}', anchor); + expect(readFileSync(plan, 'utf8')).toBe('{"new":true}'); + expect( + Math.abs(statSync(plan).mtimeMs - anchor.mtimeMs), + ).toBeLessThanOrEqual(1); + expect(readdirSync(root).filter((n) => n.includes('enrich-tmp'))).toEqual( + [], + ); + }); + + it('refuses to overwrite a capture that landed after the anchor', () => { + // The compare-and-refuse upstream leaves a read+compare+write window; a + // concurrent capture landing inside it was silently overwritten with + // stale derived contents under the OTHER run's epoch. Identity is + // re-checked against the anchor immediately before the rename. + const anchor = statSync(plan); + writeFileSync(plan, '{"captured":"by-another-run"}'); + expect(() => + commitPlanPreservingEpoch(plan, '{"stale":true}', anchor), + ).toThrow(/changed while repository context was being committed/); + expect(readFileSync(plan, 'utf8')).toBe('{"captured":"by-another-run"}'); + expect(readdirSync(root).filter((n) => n.includes('enrich-tmp'))).toEqual( + [], + ); + }); + + it('refuses when the inode moved even if the mtime was forged back', () => { + const anchor = statSync(plan); + rmSync(plan); + writeFileSync(plan, '{"captured":"by-another-run"}'); + // Forge the anchor's mtime onto the imposter: the inode still differs. + utimesSync(plan, anchor.atimeMs / 1000, anchor.mtimeMs / 1000); + expect(() => + commitPlanPreservingEpoch(plan, '{"stale":true}', anchor), + ).toThrow(/changed while repository context was being committed/); + }); +}); diff --git a/packages/cli/src/commands/review/repo-context.ts b/packages/cli/src/commands/review/repo-context.ts index 5acfe2675c3..39bcef8c49d 100644 --- a/packages/cli/src/commands/review/repo-context.ts +++ b/packages/cli/src/commands/review/repo-context.ts @@ -7,12 +7,19 @@ import type { CommandModule } from 'yargs'; import { atomicWriteFileSync } from '@qwen-code/qwen-code-core'; import { + closeSync, existsSync, + fsyncSync, mkdirSync, + openSync, readFileSync, realpathSync, + renameSync, + rmSync, statSync, utimesSync, + writeSync, + type Stats, } from 'node:fs'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; @@ -443,17 +450,9 @@ export function runRepoContext( planUnchanged = false; } if (!planUnchanged) { - atomicWriteFileSync(planPath, serialized); - // Seconds as a FLOAT, not the `Date` objects: a `Date` carries integer - // milliseconds, while APFS and ext4 keep nanoseconds — so restoring from - // `planStat.mtime` truncates the sub-millisecond remainder and lands - // close to the original rather than on it. The run ledger's plan fence - // now tolerates 1ms (`PLAN_MTIME_TOLERANCE_MS`), so a truncated restore - // would no longer empty it — the float restore is kept because it is - // strictly more faithful, and because the strict `since` readers - // (prompt records, transcripts) have no tolerance at all. - utimesSync(planPath, planStat.atimeMs / 1000, planStat.mtimeMs / 1000); - // The rename commits a new mtime before the restore lands. Verify it: + commitPlanPreservingEpoch(planPath, serialized, planStat); + // The temp file was stamped BEFORE the rename, so the commit should + // land exactly on the anchor. Verify it anyway: // an unrestored epoch fences out this run's own evidence, and a silent // one is worse than a loud one. Compared with a millisecond of tolerance // rather than exact float equality — `mtimeMs` is a float derived from a @@ -478,6 +477,57 @@ export function runRepoContext( ); } +/** + * Commit the enriched plan without ever exposing an advanced run epoch. + * + * The plain write-then-restore pair had two windows, both found by audit. + * (R2-11) the atomic rename commits the temp file's FRESH mtime, and the + * separate `utimesSync` restore lands a syscall later — a kill between the + * two leaves the plan enriched with an advanced epoch, and the + * skip-when-identical guard above then makes the damage permanent: a retry + * sees identical bytes, never rewrites, and nothing ever restores the + * epoch. So the TEMP file is stamped with the anchor's times BEFORE the + * rename: at every instant the plan path exists, it carries the epoch this + * run's evidence is fenced on. (R8-49) the compare-and-refuse upstream ran + * a full read+compare+write before its rename, and a concurrent capture + * landing in that window was silently overwritten with stale contents + * under the OTHER run's epoch — so identity is re-checked against the + * anchor immediately before the rename, leaving only the two adjacent + * syscalls no userspace sequence can close. + * + * Exported for its probes. + */ +export function commitPlanPreservingEpoch( + planPath: string, + serialized: string, + anchor: Stats, +): void { + const tmp = `${planPath}.${process.pid}.enrich-tmp`; + rmSync(tmp, { force: true }); + const fd = openSync(tmp, 'wx', 0o644); + try { + writeSync(fd, serialized); + fsyncSync(fd); + } finally { + closeSync(fd); + } + try { + utimesSync(tmp, anchor.atimeMs / 1000, anchor.mtimeMs / 1000); + const now = statSync(planPath); + if (Math.abs(now.mtimeMs - anchor.mtimeMs) > 1 || now.ino !== anchor.ino) { + throw new Error( + `repo-context: the plan at ${planPath} changed while repository ` + + 'context was being committed (another run captured it); aborting ' + + 'rather than overwriting its capture with stale contents.', + ); + } + renameSync(tmp, planPath); + } catch (err) { + rmSync(tmp, { force: true }); + throw err; + } +} + export const repoContextCommand: CommandModule = { command: 'repo-context', describe: 'Attach bounded repository-specific context to a review plan', From 852b9e8e54a8e4a9ca6614662e7c502b41373346 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 16 Aug 2026 17:27:48 +0800 Subject: [PATCH 21/21] test(review): give the inode probe an imposter that cannot recycle the inode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ext4 reuses a just-freed inode number immediately, so the delete-then-create fixture came back as the same (ino, mtime) identity — indistinguishable by design, and red only on Linux. The imposter is now created while the original exists and renamed over it, so its inode is distinct on every filesystem. --- packages/cli/src/commands/review/cleanup.ts | 4 ++-- packages/cli/src/commands/review/repo-context.test.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/review/cleanup.ts b/packages/cli/src/commands/review/cleanup.ts index 4d39215a02f..40603696c7c 100644 --- a/packages/cli/src/commands/review/cleanup.ts +++ b/packages/cli/src/commands/review/cleanup.ts @@ -27,8 +27,8 @@ import { clearReviewWorktreeLease } from '../../services/review-worktree-lease.j import { currentUser, getGhHost, ghApiAll, setGhHost } from './lib/gh.js'; import { parseReceiptIds } from './lib/receipt.js'; import { refExists, releaseWorktree } from './lib/git.js'; -import { readBudgetStopUnfenced, runEpochMs } from './lib/deadline.js'; -import { promptRecordDir } from './lib/prompt-record.js'; +import { readBudgetStopUnfenced } from './lib/deadline.js'; +import { promptRecordDir, runEpochMs } from './lib/prompt-record.js'; import { worktreePath, probeWorktreePath, diff --git a/packages/cli/src/commands/review/repo-context.test.ts b/packages/cli/src/commands/review/repo-context.test.ts index 1a900371bfa..a5302d94268 100644 --- a/packages/cli/src/commands/review/repo-context.test.ts +++ b/packages/cli/src/commands/review/repo-context.test.ts @@ -1285,8 +1285,14 @@ describe('commitPlanPreservingEpoch — the epoch never advances, even torn', () it('refuses when the inode moved even if the mtime was forged back', () => { const anchor = statSync(plan); - rmSync(plan); - writeFileSync(plan, '{"captured":"by-another-run"}'); + // The imposter is created WHILE the original still exists, then renamed + // over it — so its inode is guaranteed distinct. A delete-then-create + // fixture is not: ext4 recycles a just-freed inode number immediately, + // and the imposter came back as the same (ino, mtime) identity, which + // no check can (or should) tell apart. + const imposter = join(root, 'imposter.json'); + writeFileSync(imposter, '{"captured":"by-another-run"}'); + renameSync(imposter, plan); // Forge the anchor's mtime onto the imposter: the inode still differs. utimesSync(plan, anchor.atimeMs / 1000, anchor.mtimeMs / 1000); expect(() =>