mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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:
1 parent
a5f6e14df3
commit
3debad2891
2 files changed
+88
-24
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user