From 8eba8b322ec5fef160127a1a76f9d3024502f25b Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 15 Oct 2026 00:38:19 +0000 Subject: [PATCH] feat(autoplan): durable checkpoint and resume --from; Capy checklist (Lane B: B12) With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every command that leaves a run at rest (gate_pending, amend_pending, complete, incomplete) publishes //: checkpoint.json listing every file's sha256, the run under run/, the owner's plan file under plan-input/ when it lives outside the run, and restore.md as the human index. Publication swaps in a fully written sibling. Checkpointing at every rest state, not only gate_pending, keeps accepted decisions from rolling back on restore. `gstack-autoplan resume --out --from ` checks containment (no absolute or .. paths, regular files only) and every hash before publishing the run with one rename. It restores a missing plan file into the run, journals `restored` with both hosts, and takes the lock. CHECKPOINT_INVALID and CHECKPOINT_TARGET_EXISTS each get a result-codes row and a troubleshooting anchor. Journal and revise staging paths are now run-relative, so a moved run recovers. DRIVER.md and the Capy skill's autoplan section get a short Capy checklist: always drive gstack-autoplan, never present a hand review as an autoplan run, relay every gate page with rec as the default, pass --owner-reply, and point the checkpoint at the private project-user Drive folder. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/DRIVER.md | 10 +++ autoplan/DRIVER.md.tmpl | 7 ++ bin/gstack-autoplan | 15 +++- bin/gstack-doctor-components.sh | 2 + capy/skill/gstack/SKILL.md | 2 + docs/troubleshooting.md | 23 ++++++ lib/autoplan-checkpoint.ts | 115 +++++++++++++++++++++++++++ lib/autoplan-revise.ts | 9 ++- lib/autoplan-run.ts | 15 ++-- lib/result-codes.ts | 10 +++ scripts/resolvers/autoplan-driver.ts | 3 +- test/autoplan-checkpoint.test.ts | 90 +++++++++++++++++++++ test/autoplan-driver.test.ts | 2 +- 13 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 lib/autoplan-checkpoint.ts create mode 100644 test/autoplan-checkpoint.test.ts diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index c31483744..818ac1893 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -38,6 +38,13 @@ line yourself; gstack never writes the plan. then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: ` means loop `next` again (Eng last). `APPROVED:` means `export`. +### On Capy + +- Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by + hand is not an autoplan run: do not present it as one. +- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest + survives machine replacement; restore with `resume --out --from /`. + ### Commands - `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--scaffold]` @@ -66,6 +73,8 @@ line yourself; gstack never writes the plan. write the artifact set; then `gstack-artifact validate /run.json`. - `gstack-autoplan status|resume --out ` read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). +- `gstack-autoplan resume --out --from ` + restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership. ## Reviewer result format @@ -119,6 +128,7 @@ is the completion check. Copy the whole directory, never single files. - `GATE_OWNER_REPLY_MISMATCH`: relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree - `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out --plan --incorporates `; settle a packaging or ordering change with `gstack-autoplan revise --out --settle `; or drop one with `gstack-autoplan revise --out --withdraw ` - `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out --plan --incorporates `; settle only packaging or ordering changes on an unchanged plan +- `CHECKPOINT_INVALID`: restore from an intact copy of `//` (its `restore.md` names the command); nothing was written to --out - `GSTACK_VERSION_CHANGED`: nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade ## Coordinator contract (inherited by every lane) diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 258f8226b..11b00c2ee 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -35,6 +35,13 @@ line yourself; gstack never writes the plan. then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: ` means loop `next` again (Eng last). `APPROVED:` means `export`. +### On Capy + +- Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by + hand is not an autoplan run: do not present it as one. +- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest + survives machine replacement; restore with `resume --out --from /`. + ### Commands {{AUTOPLAN_DRIVER_COMMANDS}} diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 154fc5a9e..e2b8766f4 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -12,7 +12,7 @@ * [--deadline ] [--spend-cap ] [--estimate-usd ] [--json] next in a loop; needs runners for both voices * gstack-autoplan answer --out --gate-rev --reply "" --by > [--owner-reply |-] [--rec-hash ] [--json] * gstack-autoplan recommend --out --gate-rev --item --option --basis "" [--by ] - * gstack-autoplan resume --out [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN + * gstack-autoplan resume --out [--from ] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN * gstack-autoplan status --out [--json] query: the manifest, no GSTACK_RESULT line * gstack-autoplan export --out [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes * gstack-autoplan release --out --attempt drop a reservation for an attempt that never ran @@ -27,6 +27,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { answerGate, applyGuidance, recommendGate } from '../lib/autoplan-gate'; +import { checkpointRun, restoreCheckpoint } from '../lib/autoplan-checkpoint'; import { admit, touchInstallLease } from '../lib/autoplan-admission'; import { exportRun } from '../lib/autoplan-export'; import { migrateRun } from '../lib/autoplan-migrate'; @@ -67,7 +68,9 @@ DRIVER: ${driverFile()} --rec-hash binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending recommend --out --gate-rev --item --option --basis "" [--by ] set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards - resume --out [--json] status --out [--json] export --out release --out --attempt + resume --out [--from ] [--json] --from restores a hash-verified checkpoint into an empty --out first + (GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command leaving the run at rest writes // and prints CHECKPOINT:) + status --out [--json] export --out release --out --attempt contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) The first next needs --plan. The native voice is dispatched by the parent from the printed prompt file (a subagent on the @@ -125,6 +128,8 @@ function mutating(out: string, json: boolean, fn: () => number): number { if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n'); const code = fn(); if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); } + const checkpoint = code === EXIT.ok ? checkpointRun(out) : undefined; + if (checkpoint) process.stderr.write(checkpoint + '\n'); return code; }); } @@ -247,9 +252,13 @@ async function main(argv: string[]): Promise { return EXIT.ok; } if (cmd === 'resume') { + const from = flag(rest, '--from'); + const restored = from !== undefined ? restoreCheckpoint(from, out) : []; return withRunLock(out, () => { const r = resumeRun(out); - const lines = [...r.lines, resultLineFor(r.state.status, out)]; + const checkpoint = checkpointRun(out); + if (checkpoint) process.stderr.write(checkpoint + '\n'); + const lines = [...restored, ...r.lines, resultLineFor(r.state.status, out)]; emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out }); return EXIT.ok; }, { steal: true }); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index f3d9c7962..32d6ef5e0 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -186,6 +186,8 @@ gstack_result_anchor() { AMENDMENT_UNKNOWN) printf '%s' 'amendment-unknown' ;; AMENDMENT_NOT_SETTLEABLE) printf '%s' 'amendment-not-settleable' ;; GATE_REC_STALE) printf '%s' 'gate-rec-stale' ;; + CHECKPOINT_INVALID) printf '%s' 'checkpoint-invalid' ;; + CHECKPOINT_TARGET_EXISTS) printf '%s' 'checkpoint-target-exists' ;; OWNER_REPLY_REQUIRED) printf '%s' 'owner-reply-required' ;; GATE_OWNER_REPLY_MISMATCH) printf '%s' 'gate-owner-reply-mismatch' ;; GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;; diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 3be1861e6..5abdf1daa 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -38,6 +38,8 @@ If no specific skill was requested, use the router's task-to-skill mapping. Read For `/autoplan` driven from Capy, read `autoplan/DRIVER.md` in the checkout (`~/.local/share/gstack/autoplan/DRIVER.md`): the compact phase loop, artifact contract and gate protocol, without the interactive preamble. A checkout without that file predates it; follow its `autoplan/SKILL.md` instead. Run every command with the `GSTACK_LAUNCH` assignments and read outputs as files, not prose: the run directory holds `run.json`, `decisions.jsonl`, `findings.jsonl`, `tasks.jsonl`, `timing.json`, `plan.md` and `review-record.md`, and the run ends with `GSTACK_RESULT: skill=autoplan status=gate_pending run=`. `~/.local/share/gstack/bin/gstack-artifact validate /run.json` is the completion check. The gate is answered through `gstack-autoplan answer` (or `gstack-gate parse`) with the printed `gate_rev`; an unattended run never approves its own gate. `docs/unattended.md` in the checkout is the contract. +Capy checklist for `/autoplan`: always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`, and never present a plan you reviewed by hand as an autoplan run. Relay the gate with `gstack-gate render /gate.json --all` (every page), offer `rec` (take every recommendation, plan changes included) as the one-word default, and answer with `--by agent: --owner-reply ""`. Launch every `gstack-autoplan` command with `GSTACK_AUTOPLAN_CHECKPOINT_DIR=/home/user/.capy/drive//autoplan-checkpoints` (the private project-user Drive folder) so a run at rest survives machine replacement; on a new machine, `gstack-autoplan resume --out --from /` restores it (each checkpoint's `restore.md` names the command). + ## Before any push or pull request In a repository with a sealed configuration (a `sealed` key in its `.gstack/pregate.json`), run `~/.local/share/gstack/bin/gstack-pregate sealed` before every push and before creating a pull request: Capy's pull-request tool may not run local git hooks, so this is the check that keeps sealed or eval output out of a public repository. A refusal names the file, line and rule; do not push until it passes. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bd10459f6..269c80838 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2745,6 +2745,29 @@ the render the owner answered. **Fix.** Render again (`gstack-gate render /gate.json --all`), relay it, and answer with `--rec-hash ` from that render. + +### `CHECKPOINT_INVALID` + +**Meaning.** `gstack-autoplan resume --out --from ` refused +the checkpoint: `checkpoint.json` is unreadable or not a schema_version 1 +checkpoint, or a listed file is missing, is not a regular file, escapes the +checkpoint directory, or does not match its recorded sha256. Nothing was +written to `--out`. + +**Fix.** Restore from an intact copy. With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` +set, every command that leaves the run at rest rewrites +`//` (its `restore.md` names the exact command); a copy that was +edited or partially synced fails here by design. + + +### `CHECKPOINT_TARGET_EXISTS` + +**Meaning.** `resume --from` restores only into a missing or empty `--out` +directory, and this one already holds files. + +**Fix.** Pass a new `--out` directory, or run `gstack-autoplan resume --out +` without `--from` to resume the run already there. + ### `OWNER_REPLY_REQUIRED` diff --git a/lib/autoplan-checkpoint.ts b/lib/autoplan-checkpoint.ts new file mode 100644 index 000000000..687c4a28c --- /dev/null +++ b/lib/autoplan-checkpoint.ts @@ -0,0 +1,115 @@ +/** + * autoplan-checkpoint — an opt-in durable copy of a run directory (next-wave + * B12). With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every mutating command that + * leaves the run at rest (gate_pending, amend_pending, complete, incomplete) + * publishes `//`: `checkpoint.json` (every file's sha256), the run + * files under `run/`, the owner's plan file under `plan-input/` when it lives + * outside the run, and `restore.md`, the human index. Publication swaps a + * fully written sibling in, so a reader sees the old copy or the new one. + * `restoreCheckpoint` verifies containment and every hash before it publishes + * the run directory with one rename; the caller then resumes and owns it. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { sha256File } from './headless-artifacts'; +import { FILES, RunError, journal, now, readState, type RunState } from './autoplan-state'; + +const MANIFEST = 'checkpoint.json'; +const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'incomplete']; +interface CheckpointManifest { + schema_version: 1; run: string; status: string; gate_rev: number; revision: number; at: string; host: string; gstack_version?: string; + files: Record; + plan_input?: { original: string; file: string; sha256: string }; +} + +function runFiles(dir: string, rel = ''): string[] { + return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) return /^\.revise-v\d+$/.test(e.name) ? [] : runFiles(dir, r); + return e.isFile() && r !== FILES.lock && !r.endsWith('.tmp') ? [r] : []; + }); +} + +const copy = (from: string, to: string) => { fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(from, to); }; +const inside = (root: string, p: string) => !path.relative(root, p).startsWith('..') && !path.isAbsolute(path.relative(root, p)); + +function restoreIndex(m: CheckpointManifest, target: string): string { + return [`# autoplan checkpoint: ${m.run}`, '', + `Written ${m.at} on ${m.host} at status=${m.status}, gate_rev=${m.gate_rev}; ${Object.keys(m.files).length} files, each listed with its sha256 in checkpoint.json.`, '', + 'Restore on any machine (the target directory must not hold a run):', '', + ` gstack-autoplan resume --out --from ${target}`, '', + 'resume verifies every hash, publishes the run directory, takes ownership and prints the next step.', + ...(m.plan_input ? ['', `The owner's plan file was ${m.plan_input.original}; a copy is in ${m.plan_input.file} and is restored into the run directory when the original is missing.`] : []), ''].join('\n'); +} + +/** Publish the checkpoint when the env opts in and the run is at rest; returns the CHECKPOINT line, or nothing. */ +export function checkpointRun(out: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + const root = env.GSTACK_AUTOPLAN_CHECKPOINT_DIR; + if (!root || !fs.existsSync(path.join(out, FILES.run))) return undefined; + const state = readState(out); + if (!AT_REST.includes(state.status)) return undefined; + const target = path.join(path.resolve(root), state.run); + const tmp = `${target}.${process.pid}.tmp`; + fs.rmSync(tmp, { recursive: true, force: true }); + const files: Record = {}; + for (const rel of runFiles(state.out)) { copy(path.join(state.out, rel), path.join(tmp, 'run', rel)); files[rel] = sha256File(path.join(tmp, 'run', rel)); } + const input = state.plan_input && !inside(state.out, state.plan_input) && fs.existsSync(state.plan_input) ? state.plan_input : undefined; + const planFile = input ? `plan-input/${path.basename(input)}` : undefined; + if (input) copy(input, path.join(tmp, planFile!)); + const m: CheckpointManifest = { schema_version: 1, run: state.run, status: state.status, gate_rev: state.gate_rev ?? 0, revision: state.revision, at: now(), host: os.hostname(), + ...(state.gstack_version ? { gstack_version: state.gstack_version } : {}), files, ...(input ? { plan_input: { original: input, file: planFile!, sha256: sha256File(path.join(tmp, planFile!)) } } : {}) }; + fs.writeFileSync(path.join(tmp, 'restore.md'), restoreIndex(m, target)); + fs.writeFileSync(path.join(tmp, MANIFEST), JSON.stringify(m, null, 2) + '\n'); + const old = `${target}.${process.pid}.old`; + if (fs.existsSync(target)) fs.renameSync(target, old); + fs.renameSync(tmp, target); + fs.rmSync(old, { recursive: true, force: true }); + return `CHECKPOINT: ${target} status=${m.status} gate_rev=${m.gate_rev} files=${Object.keys(files).length}`; +} + +/** Verify a checkpoint and publish it as the run directory `out`. Nothing is written to `out` unless every hash matches. */ +export function restoreCheckpoint(from: string, out: string): string[] { + const src = path.resolve(from); + const bad = (detail: string): never => { throw new RunError('CHECKPOINT_INVALID', `${src}: ${detail}`, 3); }; + let m: CheckpointManifest; + try { m = JSON.parse(fs.readFileSync(path.join(src, MANIFEST), 'utf8')); } catch (e) { return bad(`no readable ${MANIFEST} (${(e as Error).message})`); } + if (m.schema_version !== 1 || typeof m.run !== 'string' || !m.files || typeof m.files !== 'object' || !m.files[FILES.run]) bad(`${MANIFEST} is not a schema_version 1 checkpoint with run.json listed`); + const target = path.resolve(out); + if (fs.existsSync(target) && fs.readdirSync(target).length) throw new RunError('CHECKPOINT_TARGET_EXISTS', `${target} is not empty`, 3); + const verify = (rel: string, sha: string, base: string) => { + const file = path.resolve(base, rel); + if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..') || !inside(base, file)) bad(`${rel} escapes the checkpoint`); + let st: fs.Stats; + try { st = fs.lstatSync(file); } catch { return bad(`${rel} is listed but missing`); } + if (!st.isFile()) bad(`${rel} is not a regular file`); + if (sha256File(file) !== sha) bad(`${rel} does not match its sha256 in ${MANIFEST}`); + return file; + }; + const staging = `${target}.restore-${process.pid}`; + fs.rmSync(staging, { recursive: true, force: true }); + try { + for (const [rel, sha] of Object.entries(m.files)) copy(verify(rel, sha, path.join(src, 'run')), path.join(staging, rel)); + for (const [rel, sha] of Object.entries(m.files)) verify(rel, sha, staging); + const lines: string[] = []; + if (m.plan_input) { + const copyOf = verify(m.plan_input.file, m.plan_input.sha256, src); + if (!fs.existsSync(m.plan_input.original)) { + const restored = path.join(staging, m.plan_input.file); + copy(copyOf, restored); + const state = JSON.parse(fs.readFileSync(path.join(staging, FILES.run), 'utf8')) as RunState; + state.plan_input = path.join(target, m.plan_input.file); + fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify(state, null, 2) + '\n'); + lines.push(`PLAN_INPUT: ${m.plan_input.original} is missing; restored the checkpoint copy to ${state.plan_input}`); + } else if (sha256File(m.plan_input.original) !== m.plan_input.sha256) lines.push(`PLAN_INPUT: ${m.plan_input.original} differs from the checkpoint copy; kept the file on disk`); + } + if (fs.existsSync(target)) fs.rmdirSync(target); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.renameSync(staging, target); + const state = readState(target); + journal(target, { event: 'restored', from: src, checkpoint_at: m.at, checkpoint_host: m.host, host: os.hostname(), files: Object.keys(m.files).length }); + return [`RESTORED: run=${state.run} from=${src} status=${m.status} gate_rev=${m.gate_rev} files=${Object.keys(m.files).length} (written ${m.at} on ${m.host})`, ...lines]; + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } +} diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index 785a2601c..52cee58a6 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -125,7 +125,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult { const changed = opened.filter(p => state.phases[p]!.snapshot !== newHash); const reopen = state.phase_order.filter(p => changed.includes(p) || (p === 'eng' && opened.includes('eng') && changed.length > 0)); const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined; - journal(out, { event: 'revise_intent', k, staging, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle }); + journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle }); const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : []; for (const p of added) state.phases[p] = {}; @@ -169,15 +169,16 @@ export function recoverRevise(out: string): string[] { const rows = readJournal(out); const intent = rows.filter(r => r.event === 'revise_intent').pop(); if (!intent || rows.some(r => (r.event === 'plan_revised' || r.event === 'revise_abandoned') && r.k === intent.k)) return []; - const stagedRun = path.join(intent.staging, FILES.run); + const staging = path.resolve(out, intent.staging); + const stagedRun = path.join(staging, FILES.run); if (!fs.existsSync(stagedRun)) { journal(out, { event: 'revise_abandoned', k: intent.k }); - fs.rmSync(intent.staging, { recursive: true, force: true }); + fs.rmSync(staging, { recursive: true, force: true }); return [`REVISE_RECOVERED: v${intent.k} abandoned (nothing was published)`]; } const stage = JSON.parse(fs.readFileSync(stagedRun, 'utf8')) as { state: RunState; renames: Array<[string, string]>; committed: Record; settled: Array> }; fs.writeFileSync(path.join(out, FILES.run), JSON.stringify(stage.state, null, 2) + '\n'); const state = readState(out); - publish(state, intent.staging, stage.renames, { ...stage.committed, recovered: true }, stage.settled); + publish(state, staging, stage.renames, { ...stage.committed, recovered: true }, stage.settled); return [`REVISE_RECOVERED: v${intent.k} completed (reopened=${(stage.committed.reopened as string[]).join(',') || 'none'})`]; } diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index 1f06ebd9c..bec1b801e 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -167,7 +167,7 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void { ps.outside = state.outside_enabled ? { status: 'pending', attempts: [] } : notRun ? { status: 'disabled', outcome: 'not_run', reason: notRun.reason, detail: notRun.detail } : { status: 'disabled' }; ps.prompts = prompts; - journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts }); + journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts: Object.fromEntries(Object.entries(prompts).map(([k, p]) => [k, path.relative(state.out, p)])) }); } export function nextStep(o: NextOptions): NextResult { @@ -213,7 +213,7 @@ export function nextStep(o: NextOptions): NextResult { catch (e: any) { if (e.code === 'SPEND_CAP_EXCEEDED') { state.attempt_counter--; writeState(state); throw new RunError('SPEND_CAP_EXCEEDED', e.message, 3); } throw e; } const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md')), estimate_usd: estimate, dispatched_at: now() }; bindPromptResult(row.prompt, row.result); - journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: row.prompt, prompt_sha256: sha256File(row.prompt), result: row.result, estimate_usd: estimate }); + journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), result: path.relative(state.out, row.result), estimate_usd: estimate }); (v.attempts ??= []).push(row); v.attempt = attempt; v.status = 'running'; result.attempts.push({ attempt, voice, prompt: row.prompt, result: row.result, model_family: familyHint(state, voice), new: true }); @@ -329,7 +329,7 @@ export function submitResult(o: SubmitOptions): SubmitResult { const sha = sha256File(bound); const provider = o.runner ?? 'host-subagent'; const receipt = { receipt: bind.receipt, findings_source: bind.source, reviewed_sha256: ps.snapshot, ...(bind.receipt === 'attested' ? { attest_reason: o.attest } : {}) }; - journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: bound, result_sha256: sha, model, runner: provider, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt }); + journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: path.basename(bound), result_sha256: sha, model, runner: provider, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt }); settle(path.join(state.out, FILES.spend), open.attempt, o.usageUsd ?? 'unknown'); open.state = 'submitted'; open.terminal_at = now(); Object.assign(v, { status: 'completed', model, provider, output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial); @@ -406,17 +406,18 @@ export function resumeRun(out: string): ResumeResult { if (terminal.has(r.attempt)) { // A submit that journaled but died before run.json: re-bind from the journal and the file on disk. const sub = rows.find(x => x.event === 'submitted' && x.attempt === r.attempt); - if (sub && a && a.state !== 'submitted' && fs.existsSync(sub.result) && sha256File(sub.result) === sub.result_sha256) { + const result = sub && path.resolve(out, sub.result); + if (sub && result && a && a.state !== 'submitted' && fs.existsSync(result) && sha256File(result) === sub.result_sha256) { a.state = 'submitted'; a.terminal_at = sub.ts; - Object.assign(v!, { status: 'completed', model: sub.model, provider: sub.runner ?? 'unknown', output: path.basename(sub.result), result_sha256: sub.result_sha256, findings: sub.findings, attempt: sub.attempt, + Object.assign(v!, { status: 'completed', model: sub.model, provider: sub.runner ?? 'unknown', output: path.basename(result), result_sha256: sub.result_sha256, findings: sub.findings, attempt: sub.attempt, ...(sub.receipt ? { receipt: sub.receipt, findings_source: sub.findings_source, reviewed_sha256: sub.reviewed_sha256, ...(sub.attest_reason ? { attest_reason: sub.attest_reason } : {}) } : {}) }); - state.artifacts[path.basename(sub.result)] = { path: path.basename(sub.result), sha256: sub.result_sha256 }; + state.artifacts[path.basename(result)] = { path: path.basename(result), sha256: sub.result_sha256 }; if (r.voice === 'native') state.native_model = sub.model; else state.outside_model = sub.model; rebound.push(r.attempt); } continue; } - if (!a) { (v!.attempts ??= []).push({ attempt: r.attempt, state: 'execution_unknown', prompt: r.prompt, result: r.result, estimate_usd: r.estimate_usd ?? 0, dispatched_at: r.ts }); v!.status = 'running'; } + if (!a) { (v!.attempts ??= []).push({ attempt: r.attempt, state: 'execution_unknown', prompt: path.resolve(out, r.prompt), result: path.resolve(out, r.result), estimate_usd: r.estimate_usd ?? 0, dispatched_at: r.ts }); v!.status = 'running'; } else if (a.state === 'dispatched') a.state = 'execution_unknown'; else continue; journal(out, { event: 'execution_unknown', attempt: r.attempt, phase: r.phase, voice: r.voice, by: 'resume' }); diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 3877c4b97..cbc72ad75 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -526,6 +526,16 @@ export const RESULT_CODES = { summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes', fix: 'render the gate again (`gstack-gate render /gate.json --all`), relay it, and answer with the printed `--rec-hash`', }, + CHECKPOINT_INVALID: { + anchor: 'checkpoint-invalid', + summary: 'the autoplan checkpoint cannot be restored: its manifest is unreadable, a listed file is missing, escapes the checkpoint or does not match its sha256', + fix: 'restore from an intact copy of `//` (its `restore.md` names the command); nothing was written to --out', + }, + CHECKPOINT_TARGET_EXISTS: { + anchor: 'checkpoint-target-exists', + summary: '`gstack-autoplan resume --from` restores only into an empty or missing --out directory', + fix: 'pass a new --out directory, or resume the run already there without --from', + }, OWNER_REPLY_REQUIRED: { anchor: 'owner-reply-required', summary: 'an agent answered the gate (`--by agent:`) without the owner\'s verbatim reply', diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts index 973080e73..75690fba4 100644 --- a/scripts/resolvers/autoplan-driver.ts +++ b/scripts/resolvers/autoplan-driver.ts @@ -29,6 +29,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ { cmd: 'gstack-autoplan revise --out --withdraw ', does: 'drop a pending amendment instead of applying it' }, { cmd: 'gstack-autoplan export --out ', does: 'write the artifact set; then `gstack-artifact validate /run.json`' }, { cmd: 'gstack-autoplan status|resume --out ', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, + { cmd: 'gstack-autoplan resume --out --from ', does: 'restore a hash-verified checkpoint (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership' }, ]; /** The reply grammar `gstack-gate` parses, one form per line. */ @@ -41,7 +42,7 @@ export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [ /** Result codes the loop can print, in the order a run meets them. */ export const DRIVER_CODES = ['RUN_NOT_INITIALIZED', 'PLAN_SECTIONS_INVALID', 'RUN_LOCKED', 'EXECUTION_UNKNOWN', 'SPEND_CAP_EXCEEDED', 'PHASE_NOT_AWAITING', 'ATTEMPT_MISMATCH', - 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'GSTACK_VERSION_CHANGED'] as const; + 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'CHECKPOINT_INVALID', 'GSTACK_VERSION_CHANGED'] as const; /** Fix text for codes the plan specifies and lane B-core adds to lib/result-codes.ts; the table's own fix wins once it exists. */ const PENDING_FIX: Record = { PLAN_SECTIONS_INVALID: 'the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither', diff --git a/test/autoplan-checkpoint.test.ts b/test/autoplan-checkpoint.test.ts new file mode 100644 index 000000000..264b1a1c4 --- /dev/null +++ b/test/autoplan-checkpoint.test.ts @@ -0,0 +1,90 @@ +/** + * Durable checkpoint (next-wave B12): with GSTACK_AUTOPLAN_CHECKPOINT_DIR set, + * a run at rest is copied, hash-listed, to //; journal paths are + * run-relative, so `resume --from` restores the run on a new directory (even + * with the owner's plan file gone), takes ownership, and the run answers and + * exports a directory `gstack-artifact validate` accepts. A tampered, + * escaping or misplaced restore writes 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, 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 }); + +function checkpointed(name: string): { dir: string; out: string; cp: string; run: string; e: Record } { + const dir = path.join(TMP, name); + const cpRoot = path.join(dir, 'drive'); + const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: cpRoot }); + const out = startSmallRun(dir); + runPhase(out, 'ceo'); + const next = autoplan(['next', '--out', out], { env: e }); + expect(next.stderr).not.toContain('CHECKPOINT:'); + for (const voice of ['native', 'outside'] as const) review(path.join(out, `eng-${voice}-prompt.md`)); + const run = readRun(out); + 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 cp = path.join(cpRoot, run.run); + expect(gated.stderr).toMatch(new RegExp(`^CHECKPOINT: ${cp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} status=gate_pending gate_rev=1 files=\\d+$`, 'm')); + return { dir, out, cp, run: run.run, e }; +} + +describe('checkpoint and restore', () => { + test('write at gate_pending, delete the run and the plan file, restore, resume, answer and export: validate passes', () => { + const c = checkpointed('roundtrip'); + const manifest = JSON.parse(fs.readFileSync(path.join(c.cp, 'checkpoint.json'), 'utf8')); + expect(manifest).toMatchObject({ schema_version: 1, run: c.run, status: 'gate_pending', gate_rev: 1 }); + 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.cp, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out --from ${c.cp}`); + 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 moved = path.join(c.dir, 'elsewhere', 'run'); + const resumed = autoplan(['resume', '--out', moved, '--from', c.cp], { 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 files=\\d+ `, 'm')); + expect(resumed.stdout).toMatch(/^PLAN_INPUT: .*plan\.md is missing; restored the checkpoint copy to .*elsewhere\/run\/plan-input\/plan\.md$/m); + expect(resumed.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${moved}`); + expect(journal(moved).find(r => r.event === 'restored')).toMatchObject({ host: os.hostname() }); + const state = readRun(moved); + expect(state.plan_input).toBe(path.join(moved, 'plan-input', 'plan.md')); + expect(state.active_plan).toBe(path.join(moved, 'plan-active.md')); + const answered = autoplan(['answer', '--out', moved, '--gate-rev', '1', '--reply', 'as-is', '--by', 'human'], { env: c.e }); + expect(answered.status, answered.stderr).toBe(0); + expect(answered.stdout).toContain('status=complete'); + expect(answered.stderr).toMatch(/^CHECKPOINT: .* status=complete gate_rev=1 /m); + expect(autoplan(['export', '--out', moved]).status).toBe(0); + const v = validate(moved); + expect(v.status, v.stdout + v.stderr).toBe(0); + }); + + test('a tampered file, an escaping path and a non-empty target are refused and write nothing', () => { + const c = checkpointed('refusals'); + 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', c.cp]); + expect(busy.status).toBe(3); + expect(busy.stderr).toMatch(/\(CHECKPOINT_TARGET_EXISTS\)/); + expect(fs.readdirSync(nonEmpty)).toEqual(['keep.txt']); + fs.appendFileSync(path.join(c.cp, 'run', 'gate.json'), ' '); + const target = path.join(c.dir, 'restored'); + const tampered = autoplan(['resume', '--out', target, '--from', c.cp]); + expect(tampered.status).toBe(3); + expect(tampered.stderr).toMatch(/gate\.json does not match its sha256.*\(CHECKPOINT_INVALID\)/); + expect(fs.existsSync(target)).toBe(false); + const m = JSON.parse(fs.readFileSync(path.join(c.cp, 'checkpoint.json'), 'utf8')); + fs.writeFileSync(path.join(c.cp, 'checkpoint.json'), JSON.stringify({ ...m, files: { ...m.files, '../../escape.md': m.files['run.json'] } })); + const escaping = autoplan(['resume', '--out', target, '--from', c.cp]); + expect(escaping.status).toBe(3); + expect(escaping.stderr).toMatch(/escapes the checkpoint.*\(CHECKPOINT_INVALID\)/); + expect(fs.existsSync(target)).toBe(false); + }); +}); diff --git a/test/autoplan-driver.test.ts b/test/autoplan-driver.test.ts index 40adbad48..b7173dcdd 100644 --- a/test/autoplan-driver.test.ts +++ b/test/autoplan-driver.test.ts @@ -42,7 +42,7 @@ describe('autoplan/DRIVER.md content comes from the code', () => { test('the plan-specified commands and the amendment interlock are present', () => { for (const token of ['submit --out --phase

--voice native|outside --record', '--attest ""', '--unavailable|--failed', - 'revise --out

--plan --incorporates ', '--withdraw ', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render /gate.json --all', '--settle ', '--owner-reply', 'PLAN_AMEND_PENDING', 'APPLY:']) { + 'revise --out --plan --incorporates ', '--withdraw ', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render /gate.json --all', '--settle ', '--owner-reply', '--from ', 'PLAN_AMEND_PENDING', 'APPLY:']) { expect(doc).toContain(token); } });