Files
gstack/test/autoplan-checkpoint.test.ts
T
garrytanandcapy-ai[bot] 34c2411cf5 fix(autoplan): checkpoints never touch the live run; generations, newest-valid restore, validated staging (integration items 2-6)
2. checkpointRun canonicalizes the collection and the run directory (symlinks
   resolved through the deepest existing ancestor) and refuses, before
   touching anything, when they are the same or one contains the other
   (CHECKPOINT_OVERLAP); restore applies the same check to source, target and
   staging. Before, <root>/<run> equal to the live run replaced it.
3. Publication writes an immutable generation (generations/g<revision>-…),
   renames it into place complete, then switches current.json with one
   rename; the previous generation is kept, so an interruption at the publish
   boundary (fault hook checkpoint-before-pointer) leaves the last checkpoint
   current. A generation never replaces a newer one (higher run revision):
   "CHECKPOINT: skipped … newer". Publication and restore hold the
   collection's .lock; restore also holds <out>.lock.
4. Restore checks every listed file's real path stays inside the generation
   (an ancestor symlink no longer copies outside files), parses run.json and
   runs validateRun on the staged run before publishing; a refusal leaves
   --out absent. readState reroots plan_input and plan_source for moved runs,
   and a restore with the owner's plan file gone points both at the restored
   copy, so a second restore cycle and a later revise work.
5. A checkpoint failure after a committed command prints
   "CHECKPOINT: failed for <run>: … ; the command's result stands" on stderr
   and keeps the command's exit code.
6. `gstack-autoplan checkpoints --from <dir>` lists generations newest first
   (run, gate_rev, status, revision, time, hash ok|bad, current).
   `resume --from <collection or root>` restores the newest generation that
   verifies, printing each corrupt newer one it skipped; a root holding
   several runs must name one.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-15 01:35:41 +00:00

184 lines
13 KiB
TypeScript

/**
* Durable checkpoint (next-wave B12, integration fixes): with
* GSTACK_AUTOPLAN_CHECKPOINT_DIR set, a run at rest publishes an immutable,
* hash-listed generation under <dir>/<run>/generations/ and switches
* current.json to it; journal paths are run-relative, so `resume --from`
* restores the run on a new directory (even with the owner's plan file gone,
* twice over), takes ownership, and the run answers, revises and exports a
* directory `gstack-artifact validate` accepts. A checkpoint never replaces
* or deletes the live run, never replaces a newer generation, and a failure
* after a committed command is a warning. Restore picks the newest valid
* generation and refuses tampered, escaping (by path or symlink), invalid or
* overlapping input, writing nothing.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ROOT, SMALL_PLAN, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-checkpoint-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
const validate = (out: string) => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const current = (collection: string) => JSON.parse(fs.readFileSync(path.join(collection, 'current.json'), 'utf8'));
const generations = (collection: string) => fs.readdirSync(path.join(collection, 'generations')).sort();
/** A small run brought to gate_pending with the checkpoint env on for the last submit. */
function checkpointed(name: string): { dir: string; out: string; root: string; collection: string; run: string; e: Record<string, string> } {
const dir = path.join(TMP, name);
const root = path.join(dir, 'drive');
const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root });
const out = startSmallRun(dir);
runPhase(out, 'ceo');
expect(autoplan(['next', '--out', out], { env: e }).stderr).not.toContain('CHECKPOINT:');
for (const voice of ['native', 'outside'] as const) review(path.join(out, `eng-${voice}-prompt.md`));
expect(autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(out, 'eng', 'native'), '--model', 'anthropic/claude-opus-4-7'], { env: e }).status).toBe(0);
const gated = autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'outside', '--result', resultOf(out, 'eng', 'outside'), '--model', 'openai/gpt-6-astra'], { env: e });
expect(gated.stdout).toContain('status=gate_pending');
const run = readRun(out).run;
const collection = path.join(root, run);
expect(gated.stderr).toMatch(new RegExp(`^CHECKPOINT: ${esc(path.join(collection, 'generations'))}/g\\d{6}-\\S+ status=gate_pending gate_rev=1 revision=\\d+ files=\\d+$`, 'm'));
return { dir, out, root, collection, run, e };
}
describe('checkpoint and restore', () => {
test('write at gate_pending, delete the run and the plan file, restore twice, answer, revise and export: validate passes', () => {
const c = checkpointed('roundtrip');
const [gen] = generations(c.collection);
expect(current(c.collection)).toMatchObject({ generation: gen, run: c.run, status: 'gate_pending', gate_rev: 1 });
const manifest = JSON.parse(fs.readFileSync(path.join(c.collection, 'generations', gen!, 'checkpoint.json'), 'utf8'));
expect(Object.keys(manifest.files)).toEqual(expect.arrayContaining(['run.json', 'attempts.jsonl', 'gate.json', 'decisions.jsonl']));
expect(Object.keys(manifest.files)).not.toContain('run.lock');
expect(fs.readFileSync(path.join(c.collection, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out <new run dir> --from ${c.collection}`);
for (const r of journal(c.out)) for (const k of ['prompt', 'result', 'staging']) if (typeof r[k] === 'string') expect(path.isAbsolute(r[k])).toBe(false);
fs.rmSync(c.out, { recursive: true, force: true });
fs.rmSync(path.join(c.dir, 'plan.md'));
const first = path.join(c.dir, 'first', 'run');
const resumed = autoplan(['resume', '--out', first, '--from', c.collection], { env: c.e });
expect(resumed.status, resumed.stderr).toBe(0);
expect(resumed.stdout).toMatch(new RegExp(`^RESTORED: run=${c.run} from=.* status=gate_pending gate_rev=1 revision=\\d+ files=\\d+ `, 'm'));
expect(resumed.stdout).toMatch(/^PLAN_INPUT: .*plan\.md is missing; restored the checkpoint copy to .*first\/run\/plan-input\/plan\.md$/m);
expect(resumed.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${first}`);
expect(journal(first).find(r => r.event === 'restored')).toMatchObject({ host: os.hostname() });
expect(resumed.stderr).toMatch(/^CHECKPOINT: .* status=gate_pending /m);
fs.rmSync(first, { recursive: true, force: true });
const second = path.join(c.dir, 'second', 'run');
expect(autoplan(['resume', '--out', second, '--from', c.collection]).status).toBe(0);
const state = readRun(second);
expect(state.plan_input).toBe(path.join(second, 'plan-input', 'plan.md'));
expect(state.plan_source.startsWith(second + path.sep)).toBe(true);
expect(state.active_plan).toBe(path.join(second, 'plan-active.md'));
const answered = autoplan(['answer', '--out', second, '--gate-rev', '1', '--reply', 'p1d', '--by', 'human'], { env: c.e });
expect(answered.status, answered.stderr).toBe(0);
fs.writeFileSync(state.plan_input, SMALL_PLAN.replace('Tests cover', 'Ship behind RETRY_BACKOFF=1. Tests cover'));
const revised = autoplan(['revise', '--out', second, '--plan', state.plan_input]);
expect(revised.status, revised.stderr).toBe(0);
runPhase(second, 'ceo');
runPhase(second, 'eng');
expect(autoplan(['answer', '--out', second, '--gate-rev', '2', '--reply', 'as-is', '--by', 'human']).stdout).toContain('status=complete');
expect(autoplan(['export', '--out', second]).status).toBe(0);
const v = validate(second);
expect(v.status, v.stdout + v.stderr).toBe(0);
});
test('`checkpoints` lists every generation; a corrupt newest is skipped for the newest valid; an interrupted publish keeps the previous current', () => {
const c = checkpointed('generations');
const [older] = generations(c.collection);
const faulted = autoplan(['export', '--out', c.out], { env: { ...c.e, GSTACK_AUTOPLAN_FAULT: 'checkpoint-before-pointer' } });
expect(faulted.status, faulted.stderr).toBe(0);
expect(faulted.stderr).toMatch(/^CHECKPOINT: failed for .*fault injected: checkpoint interrupted before switching current\.json.*; the command's result stands, the previous checkpoint stays current$/m);
expect(current(c.collection).generation).toBe(older);
const newer = generations(c.collection).find(g => g !== older)!;
const listed = autoplan(['checkpoints', '--from', c.root]);
expect(listed.status, listed.stderr).toBe(0);
expect(listed.stdout.trim().split('\n')).toEqual([
expect.stringMatching(new RegExp(`^CHECKPOINT: \\S+/${newer} run=${c.run} gate_rev=1 status=gate_pending revision=\\d+ at=\\S+ hash=ok$`)),
expect.stringMatching(new RegExp(`^CHECKPOINT: \\S+/${older} run=${c.run} gate_rev=1 status=gate_pending revision=\\d+ at=\\S+ hash=ok current$`)),
]);
fs.appendFileSync(path.join(c.collection, 'generations', newer, 'run', 'gate.json'), ' ');
expect(autoplan(['checkpoints', '--from', c.collection]).stdout).toMatch(new RegExp(`${newer} .* hash=bad \\(gate\\.json does not match its sha256 in checkpoint\\.json\\)$`, 'm'));
const target = path.join(c.dir, 'restored');
const explicit = autoplan(['resume', '--out', target, '--from', path.join(c.collection, 'generations', newer)]);
expect(explicit.status).toBe(3);
expect(explicit.stderr).toMatch(/gate\.json does not match its sha256.*\(CHECKPOINT_INVALID\)/);
expect(fs.existsSync(target)).toBe(false);
const newest = autoplan(['resume', '--out', target, '--from', c.collection]);
expect(newest.status, newest.stderr).toBe(0);
expect(newest.stdout).toMatch(new RegExp(`^CHECKPOINT: skipped \\S+/${newer} \\(gate\\.json does not match`, 'm'));
expect(newest.stdout).toMatch(new RegExp(`^RESTORED: run=${c.run} from=\\S+/${older} `, 'm'));
});
test('a stale copy never replaces a newer generation', () => {
const c = checkpointed('newer');
const stale = path.join(c.dir, 'stale');
fs.cpSync(c.out, stale, { recursive: true });
for (let i = 0; i < 3; i++) expect(autoplan(['export', '--out', c.out], { env: c.e }).status).toBe(0);
const before = current(c.collection);
const r = autoplan(['export', '--out', stale], { env: c.e });
expect(r.status, r.stderr).toBe(0);
expect(r.stderr).toMatch(/^CHECKPOINT: skipped .*: it holds a newer checkpoint \(revision \d+ > \d+\); nothing replaced$/m);
expect(current(c.collection)).toEqual(before);
});
test('a checkpoint directory that is, contains or aliases the live run is refused before anything moves; the command still succeeds', () => {
const c = checkpointed('overlap');
const root = path.join(c.dir, 'root');
fs.mkdirSync(root);
const live = path.join(root, c.run);
fs.renameSync(c.out, live);
const same = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root }) });
expect(same.status, same.stderr).toBe(0);
expect(same.stderr).toMatch(/^CHECKPOINT: failed for .*\(CHECKPOINT_OVERLAP\); the command's result stands/m);
expect(readRun(live).run).toBe(c.run);
const inner = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: path.join(live, 'ckpt') }) });
expect(inner.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/);
expect(fs.existsSync(path.join(live, 'ckpt'))).toBe(false);
const aliasRoot = path.join(c.dir, 'alias');
fs.mkdirSync(aliasRoot);
fs.symlinkSync(live, path.join(aliasRoot, c.run));
const alias = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: aliasRoot }) });
expect(alias.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/);
expect(readRun(live).run).toBe(c.run);
expect(fs.existsSync(path.join(live, 'run.json'))).toBe(true);
const restoreInto = autoplan(['resume', '--out', path.join(c.collection, 'inside'), '--from', c.collection]);
expect(restoreInto.status).toBe(3);
expect(restoreInto.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/);
});
test('tampered, escaping, symlink-escaping and non-validating checkpoints and a non-empty target are refused and write nothing', () => {
const c = checkpointed('refusals');
const gen = path.join(c.collection, 'generations', generations(c.collection)[0]!);
const target = path.join(c.dir, 'restored');
const refused = (pattern: RegExp, code = 'CHECKPOINT_INVALID') => {
const r = autoplan(['resume', '--out', target, '--from', gen]);
expect(r.status).toBe(3);
expect(r.stderr).toMatch(new RegExp(`${pattern.source}.*\\(${code}\\)`));
expect(fs.existsSync(target)).toBe(false);
};
const nonEmpty = path.join(c.dir, 'busy');
fs.mkdirSync(nonEmpty, { recursive: true });
fs.writeFileSync(path.join(nonEmpty, 'keep.txt'), 'x');
const busy = autoplan(['resume', '--out', nonEmpty, '--from', gen]);
expect(busy.stderr).toMatch(/\(CHECKPOINT_TARGET_EXISTS\)/);
expect(fs.readdirSync(nonEmpty)).toEqual(['keep.txt']);
const manifestFile = path.join(gen, 'checkpoint.json');
const m = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
const snapDir = Object.keys(m.files).find(f => f.includes('/'))!.split('/')[0]!;
const outsideCopy = path.join(c.dir, 'outside-copy');
fs.cpSync(path.join(gen, 'run', snapDir), outsideCopy, { recursive: true });
fs.rmSync(path.join(gen, 'run', snapDir), { recursive: true });
fs.symlinkSync(outsideCopy, path.join(gen, 'run', snapDir));
refused(/escapes the checkpoint through a symlinked directory/);
fs.rmSync(path.join(gen, 'run', snapDir));
fs.cpSync(outsideCopy, path.join(gen, 'run', snapDir), { recursive: true });
fs.writeFileSync(manifestFile, JSON.stringify({ ...m, files: { ...m.files, '../../escape.md': m.files['run.json'] } }));
refused(/escapes the checkpoint/);
const gateFile = path.join(gen, 'run', 'gate.json');
fs.appendFileSync(gateFile, '\n');
fs.writeFileSync(manifestFile, JSON.stringify({ ...m, files: { ...m.files, 'gate.json': require('node:crypto').createHash('sha256').update(fs.readFileSync(gateFile)).digest('hex') } }));
refused(/the staged run does not validate: ARTIFACT_STALE gate\.json/);
});
});