test(docsync-fault): diagnosable prepare snapshot failures with one fresh-id retry (W3-1)

A failed installed snapshot now returns exit 25 with a stable reason code
(snapshot_exit, snapshot_timeout, snapshot_spawn_error, snapshot_invalid_record),
the helper's stderr tail redacted through lib/redact-engine with the fixture
root as <HOME> and bounded to 800 characters, and keeps the attempt's files: the
candidate the helper left, if any, and prepare-<audit-id>-failure.json. The
failure is a prepare-failed event, not a rejected interaction. One retry is
allowed under a fresh audit id; reusing the failed id, dispatching it or a third
prepare is rejected. Tested before and after candidate creation. The two
snapshot tests get an explicit 30 s timeout: the compare/inspect agreement
test runs 4.3-4.8 s alone and timed out at Bun's 5 s default under load.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-17 20:36:00 +00:00
1 parent a5f6e14df3
commit 3debad2891
2 files changed
+88 -24

No files matched your search

+49 -17
View File
@@ -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('<REDACTED-github.pat> at <HOME>/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');
+39 -7
View File
@@ -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 = '<!-- DOCSYNC_CHECKPOINT -->';
@@ -57,21 +58,40 @@ function locked<T>(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 <HOME>, at most SNAPSHOT_STDERR_LIMIT characters. */
function stderrExcerpt(root: string, stderr: string): string {
const redacted = redactFindingSpans(stderr.replaceAll(root, '<HOME>').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<stri
if (action === 'prepare') {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(args.audit_id ?? '')) throw Error('prepare requires a fresh literal audit id');
if (s.tasks.some(t => !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<stri
let snapshot: ReturnType<typeof installedSnapshot>;
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('<branch>', candidate.branch)
@@ -136,6 +167,7 @@ export function docsActorCommand(file: string, action: string, args: Record<stri
if (!args.audit_id || args.run_in_background !== 'false') throw Error('dispatch requires identity and explicit foreground flag');
if (s.tasks.some(t => !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');