/** * Durable checkpoint (next-wave B12, integration fixes): with * GSTACK_AUTOPLAN_CHECKPOINT_DIR set, a run at rest publishes an immutable, * hash-listed generation under //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 only, so the collection holds one * generation (field list 2, W7: a bound voice or a phase transition with the env on publishes one too; see * test/autoplan-durability.test.ts). */ function checkpointed(name: string): { dir: string; out: string; root: string; collection: string; run: string; e: Record } { 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]).status).toBe(0); 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']).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 --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/); }); });