mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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 <dir>/<run>/: 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 <new dir> --from <checkpoint>` 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>
This commit is contained in:
1 parent
77ab139675
commit
8eba8b322e
13 files changed
+287
-16
No files matched your search
@@ -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: <phases>` 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 <new dir> --from <dir>/<run>`.
|
||||
|
||||
### Commands
|
||||
|
||||
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--scaffold]`
|
||||
@@ -66,6 +73,8 @@ line yourself; gstack never writes the plan.
|
||||
write the artifact set; then `gstack-artifact validate <dir>/run.json`.
|
||||
- `gstack-autoplan status|resume --out <dir>`
|
||||
read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts).
|
||||
- `gstack-autoplan resume --out <new dir> --from <checkpoint>`
|
||||
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 <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`
|
||||
- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan
|
||||
- `CHECKPOINT_INVALID`: restore from an intact copy of `<GSTACK_AUTOPLAN_CHECKPOINT_DIR>/<run>/` (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)
|
||||
|
||||
@@ -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: <phases>` 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 <new dir> --from <dir>/<run>`.
|
||||
|
||||
### Commands
|
||||
|
||||
{{AUTOPLAN_DRIVER_COMMANDS}}
|
||||
|
||||
+12
-3
@@ -12,7 +12,7 @@
|
||||
* [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices
|
||||
* gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json]
|
||||
* gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
|
||||
* gstack-autoplan resume --out <dir> [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
|
||||
* gstack-autoplan resume --out <dir> [--from <checkpoint>] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
|
||||
* gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line
|
||||
* gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes
|
||||
* gstack-autoplan release --out <dir> --attempt <id> 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 <h> binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending
|
||||
recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
|
||||
set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards
|
||||
resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
|
||||
resume --out <dir> [--from <checkpoint>] [--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 <dir>/<run>/ and prints CHECKPOINT:)
|
||||
status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
|
||||
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<number> {
|
||||
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 });
|
||||
|
||||
@@ -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' ;;
|
||||
|
||||
@@ -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=<dir>`. `~/.local/share/gstack/bin/gstack-artifact validate <dir>/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 <dir>/gate.json --all` (every page), offer `rec` (take every recommendation, plan changes included) as the one-word default, and answer with `--by agent:<id> --owner-reply "<their words>"`. Launch every `gstack-autoplan` command with `GSTACK_AUTOPLAN_CHECKPOINT_DIR=/home/user/.capy/drive/<project-user scope>/autoplan-checkpoints` (the private project-user Drive folder) so a run at rest survives machine replacement; on a new machine, `gstack-autoplan resume --out <new dir> --from <that dir>/<run>` 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.
|
||||
|
||||
@@ -2745,6 +2745,29 @@ the render the owner answered.
|
||||
**Fix.** Render again (`gstack-gate render <dir>/gate.json --all`), relay it,
|
||||
and answer with `--rec-hash <h>` from that render.
|
||||
|
||||
<a id="checkpoint-invalid"></a>
|
||||
### `CHECKPOINT_INVALID`
|
||||
|
||||
**Meaning.** `gstack-autoplan resume --out <dir> --from <checkpoint>` 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
|
||||
`<dir>/<run>/` (its `restore.md` names the exact command); a copy that was
|
||||
edited or partially synced fails here by design.
|
||||
|
||||
<a id="checkpoint-target-exists"></a>
|
||||
### `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
|
||||
<dir>` without `--from` to resume the run already there.
|
||||
|
||||
<a id="owner-reply-required"></a>
|
||||
### `OWNER_REPLY_REQUIRED`
|
||||
|
||||
|
||||
@@ -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 `<dir>/<run>/`: `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<string, string>;
|
||||
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 <new run dir> --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<string, string> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>; settled: Array<Record<string, unknown>> };
|
||||
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'})`];
|
||||
}
|
||||
+8
-7
@@ -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<VoiceState>);
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 <dir>/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 `<GSTACK_AUTOPLAN_CHECKPOINT_DIR>/<run>/` (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:<id>`) without the owner\'s verbatim reply',
|
||||
|
||||
@@ -29,6 +29,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
|
||||
{ cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' },
|
||||
{ cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' },
|
||||
{ cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
|
||||
{ cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', 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<string, string> = {
|
||||
PLAN_SECTIONS_INVALID: 'the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Durable checkpoint (next-wave B12): with GSTACK_AUTOPLAN_CHECKPOINT_DIR set,
|
||||
* a run at rest is copied, hash-listed, to <dir>/<run>/; 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<string, string> } {
|
||||
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 <new run dir> --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);
|
||||
});
|
||||
});
|
||||
@@ -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 <dir> --phase <p> --voice native|outside --record', '--attest "<reason>"', '--unavailable|--failed',
|
||||
'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', 'PLAN_AMEND_PENDING', 'APPLY:']) {
|
||||
'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', '--from <checkpoint>', 'PLAN_AMEND_PENDING', 'APPLY:']) {
|
||||
expect(doc).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user