mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
fix(docsync-fault): prepare writes the production candidate through the installed snapshot (W3-1)
The fault actor prepare wrote its own {base_sha, content_hashes, ...} record,
which the installed gstack-docs-candidate compare rejects, while
ship/sections/documentation.md requires running compare; 79 of 198 docsync
captures (and the red in PR gate run 38990111230) were pushed off the
documented path by it.
prepare now runs the installed snapshot under a 30 s timeout for the same
selection, returns its exact command as snapshot_command so the post-child
rerun is literal, publishes prompt and event only after a valid record
exists, and removes a record its own failed snapshot left. dispatch observes
the candidate with the same installed snapshot; inspect returns base, index
and per-file git blob ids in the record representation; the legacy re-audit
check reads selected/hashes. The fault prompt names snapshot_command and the
new field names.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
a3662c47b5
commit
2b125f1bcf
4 files changed
+167
-42
No files matched your search
@@ -3,7 +3,8 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DOC_PATH, docsCandidate, fixtureDocs, repoSnapshot } from './helpers/docsync-fixture';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { DOC_PATH, docsSnapshotArgv, fixtureDocs, gitAt, gitBlobId, repoSnapshot } from './helpers/docsync-fixture';
|
||||
import { DOCS_CHECKPOINT_MARKER, DOCS_SEEDED_AUDIT_ID, docsActorCommand, docsActorHook, docsActorSeeded, installDocsActor, seedDocsFirstAttempt, type DocsActorState } from './helpers/docsync-fault-actor';
|
||||
import { docsActorVerdict } from './helpers/docsync-fault-eval';
|
||||
import { extractDocsDispatch, parseDocsCompletion } from './helpers/docsync-contract';
|
||||
@@ -61,9 +62,14 @@ test('prepare copies the exact generated prompt and snapshots actual inputs with
|
||||
expect(response.exit).toBe(0);
|
||||
const prepared = JSON.parse(response.text);
|
||||
const candidate = JSON.parse(fs.readFileSync(prepared.candidate, 'utf8'));
|
||||
expect(candidate).toEqual(docsCandidate(fixture.repo, 'first', 'edit', candidate.base_sha));
|
||||
const independent = path.join(fixture.home, 'independent-first.json');
|
||||
const argv = docsSnapshotArgv(fixture, 'first', independent);
|
||||
expect(spawnSync(argv[0]!, argv.slice(1), { cwd: fixture.repo, encoding: 'utf8', timeout: 30_000 }).status).toBe(0);
|
||||
expect(candidate).toEqual(JSON.parse(fs.readFileSync(independent, 'utf8')));
|
||||
expect(prepared.snapshot_command).toBe(docsSnapshotArgv(fixture, 'first', prepared.candidate).join(' '));
|
||||
const supplied = JSON.parse(fs.readFileSync(path.join(fixture.home, 'candidate.json'), 'utf8'));
|
||||
expect(candidate.selected_paths).toEqual(supplied.selected_paths);
|
||||
expect(candidate.selected).toEqual(supplied.selected_paths);
|
||||
expect(candidate.docs).toEqual([DOC_PATH]);
|
||||
expect(fs.readFileSync(fixture.invocation, 'utf8')).toContain('prepare saves the same selection with current hashes, so it needs no separate Read.');
|
||||
const source = extractDocsDispatch(fs.readFileSync(path.join(fixture.skills, 'ship/sections/documentation.md'), 'utf8'));
|
||||
expect(fs.readFileSync(prepared.prompt, 'utf8')).toBe(source.replaceAll('${HOME}', fixture.home)
|
||||
@@ -77,8 +83,8 @@ test('prepare copies the exact generated prompt and snapshots actual inputs with
|
||||
expect(docsActorCommand(stateFile, 'dispatch', { ...prepared, run_in_background: 'false' }).exit).toBe(0);
|
||||
const refreshed = JSON.parse(docsActorCommand(stateFile, 'prepare', { audit_id: 'second' }).text);
|
||||
const second = JSON.parse(fs.readFileSync(refreshed.candidate, 'utf8'));
|
||||
expect(second.content_hashes['app.ts']).not.toBe(candidate.content_hashes['app.ts']);
|
||||
expect(second.content_hashes['app.ts']).toBe(createHash('sha256').update(fs.readFileSync(path.join(fixture.repo, 'app.ts'))).digest('hex'));
|
||||
expect(second.hashes['app.ts']).not.toBe(candidate.hashes['app.ts']);
|
||||
expect(second.hashes['app.ts']).toBe(gitAt(fixture.repo, 'hash-object', 'app.ts'));
|
||||
expect(fs.readFileSync(prepared.candidate, 'utf8')).toBe(firstBytes);
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
expect(state.acceptedId).toBeNull();
|
||||
@@ -109,6 +115,76 @@ test('prepare rejects path escapes, symlink destinations and an unsettled writer
|
||||
} finally { fixture.clean(); }
|
||||
});
|
||||
|
||||
// PR gate run 38990111230 and 79 of 198 docsync captures: prepare wrote a record the installed compare
|
||||
// rejected ("not a gstack-docs-candidate record"), so every fault case was pushed off the documented path.
|
||||
test('prepare, the installed compare and inspect agree for unchanged inputs, a doc edit, an index change and a deletion', () => {
|
||||
const changes: Array<[string, (repo: string) => void, Record<string, unknown>, (o: any, c: any) => void]> = [
|
||||
['unchanged', () => {}, { head_changed: false, index_changed: false, content_changed: [], newly_dirty: [] }, (o, c) => {
|
||||
for (const rel of Object.keys(c.hashes)) expect(o.files[rel]?.hash ?? null).toBe(c.hashes[rel]);
|
||||
}],
|
||||
['doc edit', repo => fs.appendFileSync(path.join(repo, DOC_PATH), 'Edited.\n'),
|
||||
{ head_changed: false, index_changed: false, content_changed: [DOC_PATH], newly_dirty: [] },
|
||||
(o, c) => expect(o.files[DOC_PATH].hash).not.toBe(c.hashes[DOC_PATH])],
|
||||
['index change', repo => gitAt(repo, 'add', 'personal-note.txt'),
|
||||
{ head_changed: false, index_changed: true, content_changed: [], newly_dirty: [] },
|
||||
(o, c) => expect(o.index).not.toEqual(c.index)],
|
||||
['deletion', repo => fs.unlinkSync(path.join(repo, 'app.ts')),
|
||||
{ head_changed: false, index_changed: false, content_changed: ['app.ts'], newly_dirty: [] },
|
||||
(o, c) => { expect(o.files['app.ts']).toEqual({ exists: false }); expect(c.hashes['app.ts']).toMatch(/^[0-9a-f]{40}$/); }],
|
||||
];
|
||||
for (const [label, change, expected, agree] of changes) {
|
||||
const fixture = fixtureDocs('current');
|
||||
try {
|
||||
const stateFile = installDocsActor(fixture, 'recovery');
|
||||
const prepared = JSON.parse(docsActorCommand(stateFile, 'prepare', { audit_id: 'seq' }).text);
|
||||
const candidate = JSON.parse(fs.readFileSync(prepared.candidate, 'utf8'));
|
||||
change(fixture.repo);
|
||||
const compare = spawnSync(path.join(fixture.skills, 'bin/gstack-docs-candidate'), ['compare', prepared.candidate],
|
||||
{ cwd: fixture.repo, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(compare.status, `${label}: ${compare.stderr}`).toBe(0);
|
||||
expect(JSON.parse(compare.stdout), label).toEqual({ audit_id: 'seq', ...expected });
|
||||
const observation = JSON.parse(docsActorCommand(stateFile, 'inspect').text);
|
||||
expect(observation.base, label).toBe(candidate.base);
|
||||
expect(observation.head, label).toBe(candidate.head);
|
||||
expect(observation.index.length > 0, label).toBe(true);
|
||||
if (!expected.index_changed) expect(observation.index, label).toEqual(candidate.index);
|
||||
agree(observation, candidate);
|
||||
for (const rel of observation.inventory) if (observation.files[rel].exists) {
|
||||
expect(observation.files[rel].hash, `${label}: ${rel}`).toBe(gitBlobId(fs.readFileSync(path.join(fixture.repo, rel))));
|
||||
}
|
||||
} finally { fixture.clean(); }
|
||||
}
|
||||
});
|
||||
|
||||
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'],
|
||||
];
|
||||
for (const [label, stub] of stubs) {
|
||||
const fixture = fixtureDocs('current');
|
||||
try {
|
||||
const stateFile = installDocsActor(fixture, 'recovery');
|
||||
const helper = path.join(fixture.skills, 'bin/gstack-docs-candidate');
|
||||
const installed = fs.readFileSync(helper);
|
||||
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(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');
|
||||
} finally { fixture.clean(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('inspect returns batched read-only repo observations without private state, verdict or mutation', () => {
|
||||
const fixture = fixtureDocs('current');
|
||||
try {
|
||||
@@ -121,8 +197,8 @@ test('inspect returns batched read-only repo observations without private state,
|
||||
expect(observation.operation).toBe('inspect');
|
||||
expect(observation.head).toBe(before.head);
|
||||
expect(observation.branch).toBe('feature/docs');
|
||||
expect(observation.index).toBe(before.index);
|
||||
expect(observation.base_sha).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(observation.index).toEqual(before.index.split('\n'));
|
||||
expect(observation.base).toBe(gitAt(fixture.repo, 'rev-parse', 'main'));
|
||||
expect(typeof observation.pre_existing_dirty).toBe('string');
|
||||
expect(observation.diff_committed).toContain('widget.md.tmpl');
|
||||
expect(observation.diff_committed).toContain('Supports plain text output.');
|
||||
@@ -132,7 +208,7 @@ test('inspect returns batched read-only repo observations without private state,
|
||||
for (const rel of observation.inventory) {
|
||||
const disk = fs.readFileSync(path.join(fixture.repo, rel));
|
||||
expect(observation.files[rel].exists).toBe(true);
|
||||
expect(observation.files[rel].sha256).toBe(createHash('sha256').update(disk).digest('hex'));
|
||||
expect(observation.files[rel].hash).toBe(gitBlobId(disk));
|
||||
expect(observation.files[rel].content).toBe(disk.toString('utf8'));
|
||||
}
|
||||
for (const forbidden of ['scenario', 'tasks', 'events', 'acceptedId', 'accepted_id', 'armed', 'repaired',
|
||||
@@ -197,7 +273,7 @@ test('inspect fires the scheduled stale-after edit at the observation boundary;
|
||||
const observation = JSON.parse(docsActorCommand(stateFile, 'inspect').text);
|
||||
expect(observation.files['app.ts'].content).toBe('export const format = "json";\n');
|
||||
expect(observation.files['app.ts'].content).not.toBe(original);
|
||||
expect(observation.files['app.ts'].sha256).toBe(createHash('sha256').update(fs.readFileSync(appPath)).digest('hex'));
|
||||
expect(observation.files['app.ts'].hash).toBe(gitAt(fixture.repo, 'hash-object', 'app.ts'));
|
||||
expect(fs.readFileSync(appPath, 'utf8')).toBe('export const format = "json";\n');
|
||||
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
expect(state.armed).toBe(false);
|
||||
@@ -328,11 +404,11 @@ test('legacy verdict permits early blocking or one evidenced changed-input re-au
|
||||
expect(docsActorVerdict(actual, 'Documentation: blocked', false)).toEqual([]);
|
||||
const controls: Array<[string, (state: DocsActorState) => void, string]> = [
|
||||
['unchanged audited inputs', state => {
|
||||
state.tasks[1].observed_candidate.content_hashes = { ...state.tasks[0].observed_candidate.content_hashes };
|
||||
state.tasks[1].observed_candidate.hashes = { ...state.tasks[0].observed_candidate.hashes };
|
||||
state.tasks[1].candidate = JSON.stringify(state.tasks[1].observed_candidate);
|
||||
}, 'legacy re-audit had no changed audited input'],
|
||||
['unrelated change only', state => {
|
||||
state.tasks[1].observed_candidate.content_hashes = { ...state.tasks[0].observed_candidate.content_hashes, 'personal-note.txt': 'f'.repeat(64) };
|
||||
state.tasks[1].observed_candidate.hashes = { ...state.tasks[0].observed_candidate.hashes, 'personal-note.txt': 'f'.repeat(40) };
|
||||
state.tasks[1].candidate = JSON.stringify(state.tasks[1].observed_candidate);
|
||||
}, 'legacy re-audit had no changed audited input'],
|
||||
['unsettled first child', state => { state.tasks[0].settled = false; }, 'legacy re-audit lacks distinct settled child evidence'],
|
||||
@@ -551,7 +627,7 @@ mock.module(path.join(root, 'test/helpers/session-runner.ts'), () => ({ async ru
|
||||
const dispatch = prepared => {
|
||||
attemptsUsed++;
|
||||
checkpoint('Audit: ' + prepared.audit_id + '; candidate: ' + prepared.candidate + '; prompt: ' + prepared.prompt);
|
||||
const result = invoke('dispatch', { ...prepared, run_in_background: 'false' });
|
||||
const result = invoke('dispatch', { audit_id: prepared.audit_id, candidate: prepared.candidate, prompt: prepared.prompt, run_in_background: 'false' });
|
||||
const saved = calls.at(-2);
|
||||
expect(saved.tool).toBe('Edit');
|
||||
expect(saved.input.file_path).toBe(fixture.invocation);
|
||||
@@ -639,7 +715,7 @@ mock.module(path.join(root, 'test/helpers/session-runner.ts'), () => ({ async ru
|
||||
if (['legacy-unchanged', 'legacy-unsettled', 'legacy-fake-repair'].includes(control)) {
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
if (control === 'legacy-unchanged') {
|
||||
state.tasks[1].observed_candidate.content_hashes = { ...state.tasks[0].observed_candidate.content_hashes };
|
||||
state.tasks[1].observed_candidate.hashes = { ...state.tasks[0].observed_candidate.hashes };
|
||||
state.tasks[1].candidate = JSON.stringify(state.tasks[1].observed_candidate);
|
||||
}
|
||||
if (control === 'legacy-unsettled') state.tasks[0].settled = false;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DOC_PATH, docsCandidate, gitAt, fixtureDocs, repoSnapshot } from './docsync-fixture';
|
||||
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';
|
||||
|
||||
@@ -16,7 +18,7 @@ export interface DocsActorState {
|
||||
events: ActorEvent[];
|
||||
tasks: Array<{ id: string | null; audit_id: string; settled: boolean; stopRequested: boolean; elapsed_ms: number;
|
||||
prompt: string; candidate: string; prompt_sha256: string; candidate_sha256: string;
|
||||
observed_candidate: ReturnType<typeof docsCandidate> }>;
|
||||
observed_candidate: DocsCandidateRecord }>;
|
||||
repaired: boolean;
|
||||
armed: boolean;
|
||||
lateChanged: boolean;
|
||||
@@ -52,6 +54,34 @@ function locked<T>(file: string, body: () => T): T {
|
||||
finally { fs.closeSync(fd); fs.unlinkSync(lock); }
|
||||
}
|
||||
|
||||
const SNAPSHOT_TIMEOUT_MS = 30_000;
|
||||
const shellWord = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 record = JSON.parse(fs.readFileSync(out, 'utf8')) as DocsCandidateRecord;
|
||||
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');
|
||||
return { record, command: argv.map(shellWord).join(' ') };
|
||||
}
|
||||
|
||||
/** The candidate prepare would save now, observed without touching any saved artifact. */
|
||||
function observeCandidate(s: DocsActorState, auditId: string): DocsCandidateRecord {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-actor-observe-'));
|
||||
try { return installedSnapshot(s, auditId, path.join(dir, 'observed.json')).record; }
|
||||
finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
function changeCandidate(s: DocsActorState) {
|
||||
fs.writeFileSync(path.join(s.root, 'repo/app.ts'), 'export const format = "json";\n');
|
||||
s.lateChanged = true;
|
||||
@@ -85,17 +115,23 @@ export function docsActorCommand(file: string, action: string, args: Record<stri
|
||||
const skills = path.join(s.root, '.claude/skills/gstack');
|
||||
const candidateFile = owned(s.root, path.join(s.root, `candidate-${args.audit_id}.json`));
|
||||
const promptFile = owned(s.root, path.join(s.root, `prompt-${args.audit_id}.md`));
|
||||
if (fs.existsSync(candidateFile) || fs.existsSync(promptFile)) throw Error('prepare cannot overwrite a saved snapshot or prompt');
|
||||
const candidate = docsCandidate(repo, args.audit_id, 'edit', gitAt(repo, 'rev-parse', 'main'));
|
||||
const saved = (file: string) => fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined;
|
||||
if (saved(candidateFile) || saved(promptFile)) throw Error('prepare cannot overwrite a saved snapshot or prompt');
|
||||
const source = extractDocsDispatch(fs.readFileSync(path.join(skills, 'ship/sections/documentation.md'), 'utf8'));
|
||||
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;
|
||||
}
|
||||
const candidate = snapshot.record;
|
||||
const prompt = source.replaceAll('${HOME}', s.root).replaceAll('<branch>', candidate.branch)
|
||||
.replaceAll('<base>', 'main').replaceAll('<candidate-path>', candidateFile)
|
||||
.replaceAll('<audit-id>', args.audit_id).replaceAll('<mode>', candidate.mode)
|
||||
+ '\n\n' + docsNativeInterface({ home: s.root, repo, skills });
|
||||
fs.writeFileSync(candidateFile, JSON.stringify(candidate), { flag: 'wx', mode: 0o600 });
|
||||
fs.writeFileSync(promptFile, prompt, { flag: 'wx', mode: 0o600 });
|
||||
s.events.push({ action, audit_id: args.audit_id, detail: JSON.stringify({ candidate, prompt }) });
|
||||
text = JSON.stringify({ audit_id: args.audit_id, candidate: candidateFile, prompt: promptFile });
|
||||
s.events.push({ action, audit_id: args.audit_id, detail: JSON.stringify({ candidate, prompt, snapshot_command: snapshot.command }) });
|
||||
text = JSON.stringify({ audit_id: args.audit_id, candidate: candidateFile, prompt: promptFile, snapshot_command: snapshot.command });
|
||||
} else if (action === 'dispatch') {
|
||||
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');
|
||||
@@ -106,7 +142,7 @@ export function docsActorCommand(file: string, action: string, args: Record<stri
|
||||
const task = { id: s.scenario === 'launch-failure' ? null : `fixture-child-${s.tasks.length + 1}`, audit_id: args.audit_id, settled: true, stopRequested: false, elapsed_ms: 0,
|
||||
prompt, candidate: candidate.toString('utf8'), prompt_sha256: createHash('sha256').update(prompt).digest('hex'),
|
||||
candidate_sha256: createHash('sha256').update(candidate).digest('hex'),
|
||||
observed_candidate: docsCandidate(path.join(s.root, 'repo'), args.audit_id, 'edit', gitAt(path.join(s.root, 'repo'), 'rev-parse', 'main')) };
|
||||
observed_candidate: observeCandidate(s, args.audit_id) };
|
||||
s.events.push({ action, audit_id: args.audit_id, ...(task.id === null ? {} : { task_id: task.id }) });
|
||||
if (s.scenario === 'missing-asset') throw Error('missing installed asset must block before dispatch');
|
||||
s.tasks.push(task);
|
||||
@@ -160,11 +196,11 @@ export function docsActorCommand(file: string, action: string, args: Record<stri
|
||||
const bytes = snapshot.contents[rel];
|
||||
if (bytes === undefined) return [rel, { exists: false }];
|
||||
const buffer = Buffer.from(bytes, 'base64');
|
||||
return [rel, { exists: true, sha256: createHash('sha256').update(buffer).digest('hex'), content: buffer.toString('utf8') }];
|
||||
return [rel, { exists: true, hash: gitBlobId(buffer), content: buffer.toString('utf8') }];
|
||||
}));
|
||||
text = JSON.stringify({
|
||||
operation: 'inspect', base_sha: base, head: snapshot.head,
|
||||
branch: gitAt(repo, 'branch', '--show-current'), index: snapshot.index,
|
||||
operation: 'inspect', base, head: snapshot.head,
|
||||
branch: gitAt(repo, 'branch', '--show-current'), index: gitAt(repo, 'ls-files', '-s', '-z').split('\0').filter(Boolean),
|
||||
pre_existing_dirty: gitAt(repo, 'status', '--porcelain', '-z'),
|
||||
diff_committed: gitAt(repo, 'diff', base, 'HEAD'),
|
||||
diff_cached: gitAt(repo, 'diff', '--cached'), diff_worktree: gitAt(repo, 'diff'),
|
||||
@@ -269,7 +305,7 @@ export function seedDocsFirstAttempt(fixture: ReturnType<typeof fixtureDocs>, fi
|
||||
});
|
||||
const prepared = docsActorCommand(file, 'prepare', { audit_id: DOCS_SEEDED_AUDIT_ID });
|
||||
if (prepared.exit) throw Error(`seeded prepare failed: ${prepared.text}`);
|
||||
const { candidate, prompt } = JSON.parse(prepared.text) as { candidate: string; prompt: string };
|
||||
const { candidate, prompt, snapshot_command } = JSON.parse(prepared.text) as { candidate: string; prompt: string; snapshot_command: string };
|
||||
const dispatched = docsActorCommand(file, 'dispatch', { audit_id: DOCS_SEEDED_AUDIT_ID, candidate, prompt, run_in_background: 'false' });
|
||||
if (dispatched.exit === 24) throw Error(`seeded dispatch rejected: ${dispatched.text}`);
|
||||
const completion = path.join(fixture.home, `completion-${DOCS_SEEDED_AUDIT_ID}.md`);
|
||||
@@ -279,10 +315,10 @@ export function seedDocsFirstAttempt(fixture: ReturnType<typeof fixtureDocs>, fi
|
||||
const record = fs.readFileSync(fixture.invocation, 'utf8');
|
||||
if (record.split(DOCS_CHECKPOINT_MARKER).length !== 2) throw Error('invocation journal marker must occur exactly once');
|
||||
fs.writeFileSync(fixture.invocation, record.replace(DOCS_CHECKPOINT_MARKER, () => `### Checkpoint 1 — attempt 1 (fixture-owned prior state)
|
||||
Attempts used: 1. Prepare step 1: installed child assets present with sha256 ${assets.map(a => `${a.asset} ${a.sha256}`).join('; ')}. Prepare steps 2–3: audit ${DOCS_SEEDED_AUDIT_ID}; candidate ${candidate}; prompt ${prompt}. Launched with dispatch run_in_background=false; dispatch exit code ${dispatched.exit}; its verbatim output is saved once at ${completion}.${handle ? ` Returned child handle: ${handle}.` : ''}
|
||||
Attempts used: 1. Prepare step 1: installed child assets present with sha256 ${assets.map(a => `${a.asset} ${a.sha256}`).join('; ')}. Prepare steps 2–3: audit ${DOCS_SEEDED_AUDIT_ID}; candidate ${candidate}, written by ${snapshot_command}; prompt ${prompt}. Launched with dispatch run_in_background=false; dispatch exit code ${dispatched.exit}; its verbatim output is saved once at ${completion}.${handle ? ` Returned child handle: ${handle}.` : ''}
|
||||
Next: Parent processing for attempt 1, starting at Collect. A repeated Prepare may confirm step 1 with one literal sha256sum of these three asset paths instead of re-reading them; a missing asset or changed hash blocks before launch.
|
||||
${DOCS_CHECKPOINT_MARKER}`));
|
||||
return { auditId: DOCS_SEEDED_AUDIT_ID, candidate, prompt, completion, exit: dispatched.exit, text: dispatched.text,
|
||||
return { auditId: DOCS_SEEDED_AUDIT_ID, candidate, prompt, snapshotCommand: snapshot_command, completion, exit: dispatched.exit, text: dispatched.text,
|
||||
events: load(file).events.length };
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ export function docsActorVerdict(state: DocsActorState, report: string, publishe
|
||||
}
|
||||
const before = first.observed_candidate;
|
||||
const after = second.observed_candidate;
|
||||
const auditedPaths = new Set([...before.selected_paths, ...after.selected_paths]);
|
||||
if (![...auditedPaths].some(file => before.content_hashes[file] !== after.content_hashes[file])) {
|
||||
const auditedPaths = new Set([...before.selected, ...after.selected]);
|
||||
if (![...auditedPaths].some(file => before.hashes[file] !== after.hashes[file])) {
|
||||
failures.push('legacy re-audit had no changed audited input');
|
||||
}
|
||||
} catch {
|
||||
@@ -104,13 +104,13 @@ bun ${actorFile} stop ${stateFile} task_id=RETURNED_TASK_ID
|
||||
${docsActorCanRepair(scenario) ? `bun ${actorFile} repair ${stateFile}\nThis launcher supports one concrete marking/transport repair, only after a dispatched child is confirmed settled. That isolated repair is authorized, not approval for document risks.` : 'This launcher has no repair operation or alternate launcher. Missing assets/marking, launch failures and invalid completions cannot be repaired here. Do not invoke repair or invent an inline fallback.'}
|
||||
bun ${actorFile} publish ${stateFile} audit_id=ID report=${report}
|
||||
|
||||
prepare is a serialization convenience, not an audit or validation: after inspecting the required inputs and deciding an attempt is allowed, supply a fresh id of 1–80 letters/digits/underscores/hyphens, beginning with a letter or digit. It saves current base/HEAD/index, selected paths, dirty paths, docs roots and content hashes to a new candidate JSON, and copies the exact installed section's child prompt with literal substitutions and the observation interface to a new prompt file. It returns their paths. Read these artifacts; use the returned paths unchanged in dispatch. prepare neither launches a child nor resets/increments the attempt count, repairs content, compares snapshots or accepts an audit. Saved files are never overwritten. Use the single batched inspect transport call (declared above) to read committed, staged, unstaged and new content in one response instead of one command per file.
|
||||
prepare is a serialization convenience, not an audit or validation: after inspecting the required inputs and deciding an attempt is allowed, supply a fresh id of 1–80 letters/digits/underscores/hyphens, beginning with a letter or digit. It runs the installed gstack-docs-candidate snapshot (the section's Prepare helper) for the fixture's selection, so the new candidate JSON is the record gstack-docs-candidate compare reads, and copies the exact installed section's child prompt with literal substitutions and the observation interface to a new prompt file. It returns their paths and snapshot_command, the exact snapshot command it ran: where the section reruns the Prepare snapshot, run that command with only --out changed. Read these artifacts; use the returned paths unchanged in dispatch. prepare neither launches a child nor resets/increments the attempt count, repairs content, compares snapshots or accepts an audit. Saved files are never overwritten. Use the single batched inspect transport call (declared above) to read committed, staged, unstaged and new content in one response instead of one command per file.
|
||||
|
||||
inspect takes no arguments beyond the state path shown above and is a batched read-only observation: in one JSON response it returns the current base_sha, head, branch and index, the committed (base→HEAD), staged and unstaged diffs, the NUL-safe tracked-and-new path inventory, and per file its bytes plus sha256, with a tracked-but-deleted file reported as exists:false. It returns no verdict, acceptance, snapshot refresh, attempt, count change or publication, never exposes private transport state or precomputed gate answers, and grants no repair, risk exception, new attempt or missing-asset bypass; you still parse the returned data and apply every gate yourself. It is a real observation boundary: an independent editor may change inputs exactly at inspect time, as during any repository read, so an inspect after the child can legitimately reveal a changed input that invalidates a returned audit. ${seed ? 'inspect is the authoritative repository observation in this phase: its result already contains what git status, git rev-parse, git diff, git ls-files or cat of product files would return, so do not run those reads. One inspect after reading attempt 1\'s output serves its freshness check and any repeated Prepare. After a later child returns, one inspect serves its ownership and freshness checks and the pre-publication recheck when only private artifacts were written since. For those checks compare base_sha, head, index and each files[path].sha256 with the saved candidate, and read content or diffs only for paths whose hash differs. inspect does not substitute for reading the phase and documentation section.' : 'Read the actual phase, the installed documentation section and the child assets directly; inspect does not substitute for those reads.'}
|
||||
inspect takes no arguments beyond the state path shown above and is a batched read-only observation: in one JSON response it returns the current base, head, branch and index in the candidate's own representation, the committed (base→HEAD), staged and unstaged diffs, the NUL-safe tracked-and-new path inventory, and per file its bytes plus hash (its git blob id, the representation of the candidate's hashes), with a tracked-but-deleted file reported as exists:false. It returns no verdict, acceptance, snapshot refresh, attempt, count change or publication, never exposes private transport state or precomputed gate answers, and grants no repair, risk exception, new attempt or missing-asset bypass; you still parse the returned data and apply every gate yourself. It is a real observation boundary: an independent editor may change inputs exactly at inspect time, as during any repository read, so an inspect after the child can legitimately reveal a changed input that invalidates a returned audit. ${seed ? 'inspect is the authoritative repository observation in this phase: its result already contains what git status, git rev-parse, git diff, git ls-files or cat of product files would return, so do not run those reads. One inspect after reading attempt 1\'s output serves its freshness check and any repeated Prepare. After a later child returns, one inspect serves its ownership and freshness checks and the pre-publication recheck when only private artifacts were written since. For those checks compare base, head, index and each files[path].hash with the saved candidate\'s base, head, index and hashes[path], and read content or diffs only for paths whose hash differs. inspect does not substitute for reading the phase and documentation section.' : 'Read the actual phase, the installed documentation section and the child assets directly; inspect does not substitute for those reads.'}
|
||||
|
||||
Parent output handling (stay inside the declared interface; do not add shell to it):
|
||||
1. Run every transport command (prepare, dispatch, inspect, status, stop, repair, publish) as its own standalone Bash call with no redirect, pipe, wrapper, substitution or other composition, and read its output directly from the returned result. Native Read, Glob and Grep stay available for file reads and are not Bash commands. Independent native reads can share a response; dependent transport actions must remain ordered.
|
||||
2. Keep inspect observations in their original tool results in context and compare those returned values directly. Do not transcribe or reserialize inspect JSON into duplicate snapshot files; prepare already saves the required candidate and prompt. Never redirect a command into a file and never re-run a command merely to save its output. Compare the returned base/head/index/sha256/content/diff fields and the required asset Read results in your own reasoning. Use only the transport commands above and the commands permitted by the Fixture observation interface below; do not introduce any undeclared comparison or processing program to compare or transform observations, even read-only.
|
||||
2. Keep inspect observations in their original tool results in context and compare those returned values directly. Do not transcribe or reserialize inspect JSON into duplicate snapshot files; prepare already saves the required candidate and prompt. Never redirect a command into a file and never re-run a command merely to save its output. Compare the returned base/head/index/hash/content/diff fields and the required asset Read results in your own reasoning. Use only the transport commands above and the commands permitted by the Fixture observation interface below; do not introduce any undeclared comparison or processing program to compare or transform observations, even read-only.
|
||||
3. Persist each required checkpoint as one short appended journal entry, not a rewritten record or separate edits for each field. After reading the invocation record, use native Edit with old_string exactly ${JSON.stringify(DOCS_CHECKPOINT_MARKER)}, new_string containing only the new entry followed by that same marker, and replace_all=false. The marker must occur exactly once; if missing or duplicated, stop rather than guessing an edit. Preserve unrelated sections and every earlier entry byte-for-byte, retaining each earlier attempt's id, count, evidence paths and outcome. The latest stated value is current; do not recopy previous entries. Each entry states the current attempt count, newly learned decision/evidence and next required action. Reference saved candidate/prompt/completion artifacts instead of repeating their contents or prior narration. Before dispatch, save the incremented attempt count, fresh audit id and candidate/prompt paths together. Save the returned child handle before polling; consolidation must never postpone the pre-launch count or child-settlement checks.
|
||||
4. After the child, preserve each actual child completion/rejected output once in Markdown as the bounded-stage interface requires. Compare the saved snapshot with current files and apply the loaded output, ownership and freshness gates. If recovery is authorized, save the intermediate result in one checkpoint before continuing it.${seed ? ' When recovery continues directly into the remaining attempt, make that one checkpoint after its prepare so it is also the pre-dispatch entry: prior result and reason, any repair, incremented count, fresh id and candidate/prompt paths together.' : ''} Otherwise use the finishing checkpoint below, not an extra status-only update. A changed input requires the workflow's fresh attempt, never silently replaced hashes. Recheck freshness again before publication.
|
||||
5. After the loaded Continue or recover / Blocked recovery steps reach a final outcome, finish the required invocation state and final report before optional narration or formatting. Append status, reasons, evidence paths, pending work and any accepted post-child hashes/documentation_section in one finishing entry; do not repeat earlier gate analysis or split that known outcome across multiple edits. Append the finishing checkpoint and Write the complete report in the same response using separate native file calls, then return briefly after any authorized publication receipt. Follow the loaded gate order: when it requires stopping, write the required invocation state and report, then stop rather than continuing later preparation to fill optional artifacts. Never omit the final report or final response, even when publication is blocked.
|
||||
|
||||
@@ -36,16 +36,29 @@ export function changedFiles(before: ReturnType<typeof repoSnapshot>, after: Ret
|
||||
.filter(p => before.contents[p] !== after.contents[p]).sort();
|
||||
}
|
||||
|
||||
export function docsCandidate(repo: string, auditId: string, mode: 'edit' | 'read-only', base: string) {
|
||||
const snapshot = repoSnapshot(repo);
|
||||
return {
|
||||
audit_id: auditId, mode, base_sha: base, head: snapshot.head, branch: gitAt(repo, 'branch', '--show-current'),
|
||||
selected_paths: Object.keys(snapshot.contents).filter(p => p !== 'personal-note.txt'),
|
||||
docs_roots: ['handbook'], generated_outputs: [], index: snapshot.index,
|
||||
content_hashes: Object.fromEntries(Object.entries(snapshot.contents).map(([p, bytes]) =>
|
||||
[p, createHash('sha256').update(Buffer.from(bytes, 'base64')).digest('hex')])),
|
||||
pre_existing_dirty: gitAt(repo, 'status', '--porcelain', '-z'),
|
||||
};
|
||||
/** The record `gstack-docs-candidate snapshot` writes and `compare` reads (bin/gstack-docs-candidate). */
|
||||
export interface DocsCandidateRecord {
|
||||
schema_version: 1; audit_id: string; mode: 'edit' | 'read-only'; base: string; head: string; branch: string;
|
||||
index: string[]; committed: string[]; staged: string[]; unstaged: string[]; untracked: string[];
|
||||
selected: string[]; docs: string[]; hashes: Record<string, string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed snapshot command for the fixture's audit selection: every tracked or new path except the
|
||||
* user's personal note, with the handbook docs root, against main's commit. Returned as argv; the first
|
||||
* element is the installed helper, so the command is the literal form the observation interface admits.
|
||||
*/
|
||||
export function docsSnapshotArgv(fixture: { repo: string; skills: string }, auditId: string, out: string): string[] {
|
||||
const inventory = [...new Set(gitAt(fixture.repo, 'ls-files', '-z', '--cached', '--others', '--exclude-standard')
|
||||
.split('\0').filter(Boolean))].sort();
|
||||
return [path.join(fixture.skills, 'bin/gstack-docs-candidate'), 'snapshot', '--out', out, '--audit-id', auditId,
|
||||
'--mode', 'edit', '--base', gitAt(fixture.repo, 'rev-parse', 'main'),
|
||||
...inventory.filter(p => p !== 'personal-note.txt').flatMap(p => ['--select', p]), '--docs', 'handbook'];
|
||||
}
|
||||
|
||||
/** Git blob id of content, the hash representation of `DocsCandidateRecord.hashes`. */
|
||||
export function gitBlobId(content: Buffer): string {
|
||||
return createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex');
|
||||
}
|
||||
|
||||
export function fixtureDocs(scenario: DocsScenario, generatedRoot = process.env.DOCSYNC_GENERATED_ROOT || DOCSYNC_ROOT) {
|
||||
|
||||
Reference in new issue
Block a user