mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(lease): name the double-crash limit of breaking a dead lock Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit89bf556c11) * test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit7f6c70459a) * test: the DRIVER template names the reviewer trailer relay (pregate lane) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit2545d9a6d2) * test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commitb509096218) * feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit9335e45de2) * test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commita7a974d268) * test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog entry for field list 2 wave A Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.115.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
188 lines
13 KiB
TypeScript
188 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 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<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]).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 <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/);
|
|
});
|
|
});
|