diff --git a/test/docsync-fault-interface.test.ts b/test/docsync-fault-interface.test.ts index 7f4c6fd9a..02a95f4e7 100644 --- a/test/docsync-fault-interface.test.ts +++ b/test/docsync-fault-interface.test.ts @@ -154,15 +154,20 @@ test('prepare, the installed compare and inspect agree for unchanged inputs, a d } } finally { fixture.clean(); } } -}); +}, 30_000); -test('a snapshot failure before or after the candidate exists publishes no candidate, prompt or event', () => { - const stubs: Array<[string, string]> = [ - ['before creation', '#!/bin/sh\necho "gstack-docs-candidate: injected failure" >&2\nexit 3\n'], - ['after creation', '#!/bin/sh\nwhile [ $# -gt 0 ]; do [ "$1" = --out ] && printf \'{"schema_version":1\' > "$2"; shift; done\nexit 1\n'], - ['invalid record', '#!/bin/sh\nwhile [ $# -gt 0 ]; do [ "$1" = --out ] && printf \'{}\' > "$2"; shift; done\nexit 0\n'], +// DX confirm round 2: a failed Prepare snapshot must be diagnosable from its own output and files, and a transient +// failure gets exactly one retry under a fresh audit id; the failed id can never be dispatched or reused. +test('a snapshot failure before or after candidate creation reports a bounded redacted reason, keeps its files and allows one retry under a fresh id', () => { + const secret = 'ghp' + '_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8'; + const noisy = (exit: number) => `#!/bin/sh\nyes 'gstack-docs-candidate: noise line' | head -n 200 >&2\necho "token ${secret} at $PWD" >&2\nexit ${exit}\n`; + const writes = (body: string, exit: number) => `#!/bin/sh\nwhile [ $# -gt 0 ]; do [ "$1" = --out ] && printf '%s' '${body}' > "$2"; shift; done\necho "token ${secret} at $PWD" >&2\nexit ${exit}\n`; + const stubs: Array<[string, string, string, boolean]> = [ + ['before creation', noisy(3), 'snapshot_exit', false], + ['after creation', writes('{"schema_version":1', 1), 'snapshot_exit', true], + ['invalid record', writes('{}', 0), 'snapshot_invalid_record', true], ]; - for (const [label, stub] of stubs) { + for (const [label, stub, reason, candidateKept] of stubs) { const fixture = fixtureDocs('current'); try { const stateFile = installDocsActor(fixture, 'recovery'); @@ -171,19 +176,46 @@ test('a snapshot failure before or after the candidate exists publishes no candi fs.writeFileSync(helper, stub); const before = repoSnapshot(fixture.repo); const failed = docsActorCommand(stateFile, 'prepare', { audit_id: 'fails' }); - expect(failed.exit, label).toBe(24); - expect(failed.text, label).toContain(label === 'invalid record' ? 'installed snapshot wrote an invalid record' : 'installed snapshot failed'); - for (const file of ['candidate-fails.json', 'prompt-fails.md']) expect(fs.existsSync(path.join(fixture.home, file)), `${label}: ${file}`).toBe(false); - const state: DocsActorState = JSON.parse(fs.readFileSync(stateFile, 'utf8')); - expect(state.events.map(e => e.action), label).toEqual(['rejected']); + expect(failed.exit, label).toBe(25); + expect(failed.text, label).toContain(`prepare failed: reason=${reason} `); + expect(failed.text, label).toContain('retry at most once, with a fresh audit_id'); + expect(failed.text, label).toContain(' at /repo'); + expect(failed.text, label).not.toContain(secret); + const failureFile = path.join(fixture.home, 'prepare-fails-failure.json'); + const failure = JSON.parse(fs.readFileSync(failureFile, 'utf8')); + expect(failure.reason, label).toBe(reason); + expect(failure.stderr_excerpt.length, label).toBeLessThanOrEqual(800); + expect(failure.stderr_excerpt, label).not.toContain(fixture.home); + expect(failed.text, label).toContain(failure.stderr_excerpt); + expect(failure.stderr_excerpt.startsWith('…'), label).toBe(label === 'before creation'); + const candidate = path.join(fixture.home, 'candidate-fails.json'); + expect(failure.kept, label).toEqual(candidateKept ? [candidate, failureFile] : [failureFile]); + expect(fs.existsSync(candidate), label).toBe(candidateKept); + expect(fs.existsSync(path.join(fixture.home, 'prompt-fails.md')), label).toBe(false); expect(repoSnapshot(fixture.repo)).toEqual(before); - fs.writeFileSync(helper, installed); - const retried = docsActorCommand(stateFile, 'prepare', { audit_id: 'fails' }); - expect(retried.exit, label).toBe(0); - expect(JSON.parse(fs.readFileSync(JSON.parse(retried.text).candidate, 'utf8')).audit_id).toBe('fails'); + const reused = docsActorCommand(stateFile, 'prepare', { audit_id: 'fails' }); + expect([reused.exit, reused.text.includes('reason=snapshot_retry_needs_fresh_id')], label).toEqual([24, true]); + if (label === 'before creation') { + const second = docsActorCommand(stateFile, 'prepare', { audit_id: 'fails-again' }); + expect([second.exit, second.text.includes('retry none left')], label).toEqual([25, true]); + fs.writeFileSync(helper, installed); + const third = docsActorCommand(stateFile, 'prepare', { audit_id: 'fails-third' }); + expect([third.exit, third.text.includes('reason=snapshot_retry_exhausted')], label).toEqual([24, true]); + expect(fs.existsSync(path.join(fixture.home, 'candidate-fails-third.json'))).toBe(false); + } else { + const dispatched = docsActorCommand(stateFile, 'dispatch', { audit_id: 'fails', candidate, prompt: path.join(fixture.home, 'prompt-fails.md'), run_in_background: 'false' }); + expect([dispatched.exit, dispatched.text.includes('dispatch of a failed prepare')], label).toEqual([24, true]); + fs.writeFileSync(helper, installed); + const retried = docsActorCommand(stateFile, 'prepare', { audit_id: 'fails-retry' }); + expect(retried.exit, label).toBe(0); + expect(JSON.parse(fs.readFileSync(JSON.parse(retried.text).candidate, 'utf8')).audit_id).toBe('fails-retry'); + } + const state: DocsActorState = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state.events.map(e => e.action), label) + .toEqual(label === 'before creation' ? ['prepare-failed', 'rejected', 'prepare-failed', 'rejected'] : ['prepare-failed', 'rejected', 'rejected', 'prepare']); } finally { fixture.clean(); } } -}); +}, 30_000); test('inspect returns batched read-only repo observations without private state, verdict or mutation', () => { const fixture = fixtureDocs('current'); diff --git a/test/helpers/docsync-fault-actor.ts b/test/helpers/docsync-fault-actor.ts index 3069af744..ed0c32b52 100644 --- a/test/helpers/docsync-fault-actor.ts +++ b/test/helpers/docsync-fault-actor.ts @@ -6,6 +6,7 @@ import { spawnSync } from 'node:child_process'; import { DOC_PATH, docsSnapshotArgv, gitAt, gitBlobId, fixtureDocs, repoSnapshot, type DocsCandidateRecord } from './docsync-fixture'; import { extractDocsDispatch } from './docsync-contract'; import { docsNativeInterface } from './docsync-observer'; +import { redactFindingSpans } from '../../lib/redact-engine'; export const DOCS_CHECKPOINT_MARKER = ''; @@ -57,21 +58,40 @@ function locked(file: string, body: () => T): T { const SNAPSHOT_TIMEOUT_MS = 30_000; const shellWord = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`; +export const SNAPSHOT_STDERR_LIMIT = 800; +export type SnapshotFailureReason = 'snapshot_timeout' | 'snapshot_spawn_error' | 'snapshot_exit' | 'snapshot_invalid_record'; + +class SnapshotFailure extends Error { + constructor(readonly reason: SnapshotFailureReason, readonly status: string, readonly stderr: string) { super(reason); } +} + +/** The tail of the helper's stderr with secrets redacted and the fixture root replaced by , at most SNAPSHOT_STDERR_LIMIT characters. */ +function stderrExcerpt(root: string, stderr: string): string { + const redacted = redactFindingSpans(stderr.replaceAll(root, '').trim()); + if (redacted === null) return '[stderr omitted: unredactable secret]'; + return redacted.length > SNAPSHOT_STDERR_LIMIT ? '…' + redacted.slice(-(SNAPSHOT_STDERR_LIMIT - 1)) : redacted; +} + /** * Runs the installed `gstack-docs-candidate snapshot` (the section's Prepare helper) for the fixture selection - * and returns the record it wrote, or throws with the helper's own error. `out` must not exist yet. + * and returns the record it wrote, or throws a SnapshotFailure with a stable reason. `out` must not exist yet. */ function installedSnapshot(s: DocsActorState, auditId: string, out: string): { record: DocsCandidateRecord; command: string } { const repo = path.join(s.root, 'repo'); const argv = docsSnapshotArgv({ repo, skills: path.join(s.root, '.claude/skills/gstack') }, auditId, out); const run = spawnSync(argv[0]!, argv.slice(1), { cwd: repo, encoding: 'utf8', timeout: SNAPSHOT_TIMEOUT_MS, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }); - if (run.error || run.status !== 0) { - throw Error(`installed snapshot failed (${run.error?.message ?? `exit ${run.status ?? run.signal}`}): ${(run.stderr || '').trim()}`); + const stderr = run.stderr || ''; + if (run.error) { + const timedOut = (run.error as NodeJS.ErrnoException).code === 'ETIMEDOUT'; + throw new SnapshotFailure(timedOut ? 'snapshot_timeout' : 'snapshot_spawn_error', run.error.message, stderr); } - const record = JSON.parse(fs.readFileSync(out, 'utf8')) as DocsCandidateRecord; + if (run.status !== 0) throw new SnapshotFailure('snapshot_exit', `exit ${run.status ?? run.signal}`, stderr); + let record: DocsCandidateRecord; + try { record = JSON.parse(fs.readFileSync(out, 'utf8')) as DocsCandidateRecord; } + catch (error) { throw new SnapshotFailure('snapshot_invalid_record', `exit 0, unreadable record: ${(error as Error).message}`, stderr); } if (record.schema_version !== 1 || record.audit_id !== auditId || record.mode !== 'edit' || record.base !== gitAt(repo, 'rev-parse', 'main') - || !Array.isArray(record.index) || !record.hashes || typeof record.hashes !== 'object') throw Error('installed snapshot wrote an invalid record'); + || !Array.isArray(record.index) || !record.hashes || typeof record.hashes !== 'object') throw new SnapshotFailure('snapshot_invalid_record', 'exit 0, invalid record', stderr); return { record, command: argv.map(shellWord).join(' ') }; } @@ -111,6 +131,9 @@ export function docsActorCommand(file: string, action: string, args: Record !t.settled)) throw Error('attempted snapshot with unsettled writer'); + const failedPrepares = s.events.filter(e => e.action === 'prepare-failed'); + if (failedPrepares.some(e => e.audit_id === args.audit_id)) throw Error(`reason=snapshot_retry_needs_fresh_id: prepare ${args.audit_id} failed; retry once with a fresh audit_id`); + if (failedPrepares.length >= 2) throw Error('reason=snapshot_retry_exhausted: prepare already failed and was retried once; report the documentation phase blocked'); const repo = path.join(s.root, 'repo'); const skills = path.join(s.root, '.claude/skills/gstack'); const candidateFile = owned(s.root, path.join(s.root, `candidate-${args.audit_id}.json`)); @@ -121,8 +144,16 @@ export function docsActorCommand(file: string, action: string, args: Record; try { snapshot = installedSnapshot(s, args.audit_id, candidateFile); } catch (error) { - if (fs.lstatSync(candidateFile, { throwIfNoEntry: false })?.isFile()) fs.unlinkSync(candidateFile); - throw error; + if (!(error instanceof SnapshotFailure)) throw error; + const failureFile = owned(s.root, path.join(s.root, `prepare-${args.audit_id}-failure.json`)); + const kept = [...(saved(candidateFile) ? [candidateFile] : []), failureFile]; + const failure = { reason: error.reason, audit_id: args.audit_id, status: error.status, + stderr_excerpt: stderrExcerpt(s.root, error.stderr), kept, + retry: failedPrepares.length ? 'none left' : 'at most once, with a fresh audit_id' }; + fs.writeFileSync(failureFile, JSON.stringify(failure, null, 2), { flag: 'wx', mode: 0o600 }); + s.events.push({ action: 'prepare-failed', audit_id: args.audit_id, detail: JSON.stringify(failure) }); + save(file, s); + return { text: `prepare failed: reason=${failure.reason} (${failure.status}); retry ${failure.retry}; kept for inspection: ${kept.join(', ')}\nstderr (redacted, last ${SNAPSHOT_STDERR_LIMIT} chars):\n${failure.stderr_excerpt}`, exit: 25 }; } const candidate = snapshot.record; const prompt = source.replaceAll('${HOME}', s.root).replaceAll('', candidate.branch) @@ -136,6 +167,7 @@ export function docsActorCommand(file: string, action: string, args: Record !t.settled)) throw Error('attempted writer overlap'); if (s.events.some(e => e.action === 'dispatch' && e.audit_id === args.audit_id)) throw Error('reused audit identity'); + if (s.events.some(e => e.action === 'prepare-failed' && e.audit_id === args.audit_id)) throw Error('dispatch of a failed prepare'); const prompt = fs.readFileSync(owned(s.root, args.prompt), 'utf8'); const candidate = fs.readFileSync(owned(s.root, args.candidate)); if (!prompt.includes('document-release') || !prompt.includes('files_updated') || !prompt.includes(args.audit_id)) throw Error('dispatch did not carry the actual workflow prompt');