mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
Merge remote-tracking branch 'origin/capy/next-wave-fix-b' into capy/next-wave
This commit is contained in:
commit
d5f3501cba
20 files changed
+669
-200
No files matched your search
+3
-3
@@ -74,7 +74,7 @@ line yourself; gstack never writes the plan.
|
||||
- `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.
|
||||
restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them.
|
||||
|
||||
## Reviewer result format
|
||||
|
||||
@@ -127,8 +127,8 @@ is the completion check. Copy the whole directory, never single files.
|
||||
- `OWNER_REPLY_REQUIRED`: pass the owner's words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>`
|
||||
- `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
|
||||
- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>` (add `--settle <id>` to settle packaging/ordering changes in the same call); settle alone only on the plan the run already reviews
|
||||
- `CHECKPOINT_INVALID`: run `gstack-autoplan checkpoints --from <dir>` to see each generation's hash status and restore an intact one; 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)
|
||||
|
||||
+25
-12
@@ -16,6 +16,7 @@
|
||||
* 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
|
||||
* gstack-autoplan checkpoints --from <dir> list checkpoint generations, newest first, with hash ok|bad
|
||||
* gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2)
|
||||
*
|
||||
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
|
||||
@@ -26,8 +27,8 @@
|
||||
*/
|
||||
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 { amendmentSteps, answerGate, recommendGate } from '../lib/autoplan-gate';
|
||||
import { checkpointLines, 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';
|
||||
@@ -69,7 +70,9 @@ DRIVER: ${driverFile()}
|
||||
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> [--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:)
|
||||
(a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command
|
||||
leaving the run at rest publishes a generation under <dir>/<run>/ and prints CHECKPOINT: on stderr)
|
||||
checkpoints --from <dir> list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
|
||||
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)
|
||||
|
||||
@@ -98,11 +101,7 @@ const resultLineFor = (status: string, out: string) => `GSTACK_RESULT: skill=aut
|
||||
const listFlag = (args: string[], name: string) => args.flatMap((a, i) => (a === name && args[i + 1] ? args[i + 1]!.split(',').map(x => x.trim()).filter(Boolean) : []));
|
||||
/** The NEXT line after an answer or revise: per pending change, settle it or edit the plan and revise; else next. */
|
||||
function nextLine(state: RunState, out: string): string {
|
||||
const pending = pendingAmendments(state);
|
||||
const settle = pending.filter(a => applyGuidance(a).startsWith('no plan edit')).map(a => a.item ?? a.decision);
|
||||
const edit = pending.filter(a => !applyGuidance(a).startsWith('no plan edit')).map(a => a.decision);
|
||||
if (!pending.length) return `NEXT: gstack-autoplan next --out ${out}`;
|
||||
return `NEXT: ${[edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : '', settle.length ? `gstack-autoplan revise --out ${out} --settle ${settle.join(',')}` : ''].filter(Boolean).join('; ')}`;
|
||||
return pendingAmendments(state).length ? `NEXT: ${amendmentSteps(state, out)}` : `NEXT: gstack-autoplan next --out ${out}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,12 +127,21 @@ 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');
|
||||
if (code === EXIT.ok) checkpointQuietly(out);
|
||||
return code;
|
||||
});
|
||||
}
|
||||
|
||||
/** A checkpoint never changes a committed command's outcome: a failure is a stderr warning, the exit code stands. */
|
||||
function checkpointQuietly(out: string): void {
|
||||
try {
|
||||
const line = checkpointRun(out);
|
||||
if (line) process.stderr.write(line + '\n');
|
||||
} catch (e: any) {
|
||||
process.stderr.write(`CHECKPOINT: failed for ${out}: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}; the command's result stands, the previous checkpoint stays current\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function emit(json: boolean, lines: string[], payload: Record<string, unknown>): void {
|
||||
if (json) process.stdout.write(JSON.stringify(payload) + '\n');
|
||||
else process.stdout.write(lines.join('\n') + '\n');
|
||||
@@ -238,6 +246,12 @@ async function main(argv: string[]): Promise<number> {
|
||||
const json = rest.includes('--json');
|
||||
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
||||
if (cmd === 'contract') { process.stdout.write(COORDINATOR_CONTRACT + '\n'); return EXIT.ok; }
|
||||
if (cmd === 'checkpoints') {
|
||||
const from = flag(rest, '--from');
|
||||
if (!from) return usage('checkpoints needs --from <checkpoint dir>');
|
||||
try { process.stdout.write(checkpointLines(from).join('\n') + '\n'); return EXIT.ok; }
|
||||
catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; }
|
||||
}
|
||||
const outFlag = flag(rest, '--out');
|
||||
if (!outFlag) return usage(`${cmd} needs --out <dir>`);
|
||||
const out = path.resolve(outFlag);
|
||||
@@ -256,8 +270,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
const restored = from !== undefined ? restoreCheckpoint(from, out) : [];
|
||||
return withRunLock(out, () => {
|
||||
const r = resumeRun(out);
|
||||
const checkpoint = checkpointRun(out);
|
||||
if (checkpoint) process.stderr.write(checkpoint + '\n');
|
||||
checkpointQuietly(out);
|
||||
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;
|
||||
|
||||
@@ -192,6 +192,7 @@ gstack_result_anchor() {
|
||||
AMENDMENT_NOT_SETTLEABLE) printf '%s' 'amendment-not-settleable' ;;
|
||||
GATE_REC_STALE) printf '%s' 'gate-rec-stale' ;;
|
||||
CHECKPOINT_INVALID) printf '%s' 'checkpoint-invalid' ;;
|
||||
CHECKPOINT_OVERLAP) printf '%s' 'checkpoint-overlap' ;;
|
||||
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' ;;
|
||||
|
||||
+41
-17
@@ -2813,14 +2813,18 @@ pending, revise without `--incorporates`:
|
||||
|
||||
**Meaning.** `gstack-autoplan revise --settle <id>` discharges an accepted change
|
||||
without a plan edit, which is honest only for a packaging or ordering change
|
||||
(the gate item's `changes` is `packaging` or `ordering`) on a plan nobody has
|
||||
edited since it was reviewed. The named change alters scope, cost or direction,
|
||||
names no `changes` value, or the plan file's Implementation plan no longer
|
||||
matches the reviewed snapshot. Nothing was changed.
|
||||
(the gate item's `changes` is `packaging` or `ordering`) on the plan the run
|
||||
already reviews: the plan the owner accepted it on, or one a later `revise`
|
||||
recorded. The named change alters scope, cost or direction, names no `changes`
|
||||
value, the plan file has edits no `revise` has recorded, or `--settle` came with
|
||||
a `--plan` that changes the Implementation plan while incorporating nothing.
|
||||
Nothing was changed.
|
||||
|
||||
**Fix.** Edit the plan, then
|
||||
`gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`.
|
||||
The `APPLY:` line for each change says which path it takes.
|
||||
`gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`,
|
||||
adding `--settle <id>` to settle packaging or ordering changes in the same call
|
||||
(or settle them after the revise). The `APPLY:` line for each change says which
|
||||
path it takes, and `NEXT:` lists the settle step first.
|
||||
|
||||
<a id="gate-rec-stale"></a>
|
||||
### `GATE_REC_STALE`
|
||||
@@ -2829,24 +2833,44 @@ The `APPLY:` line for each change says which path it takes.
|
||||
was rendered, so a `rec` (or `all`) reply the owner gave to the earlier render
|
||||
would take recommendations they never saw. Every render prints
|
||||
`rec_hash=<h>`; once `recommend` has run, a `rec` answer must name the hash of
|
||||
the render the owner answered.
|
||||
the render the owner answered. The same refusal meets a `rec`/`all` reply to a
|
||||
gate written before `rec` existed (a `gate.json` with no `rec_rev`, from
|
||||
1.91.101.0 or earlier): that render told the owner `all` leaves items
|
||||
recommended `b` pending, and `all` now takes them.
|
||||
|
||||
**Fix.** Render again (`gstack-gate render <dir>/gate.json --all`), relay it,
|
||||
and answer with `--rec-hash <h>` from that render.
|
||||
and answer with `--rec-hash <h>` from that render. Explicit `<id><option>`
|
||||
tokens are never refused.
|
||||
|
||||
<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`.
|
||||
**Meaning.** `gstack-autoplan resume --out <dir> --from <checkpoint>` found no
|
||||
generation it can restore: a `checkpoint.json` is unreadable or not a
|
||||
schema_version 1 checkpoint, a listed file is missing, is not a regular file,
|
||||
escapes the generation (an `..` path or a symlinked directory), or does not
|
||||
match its recorded sha256; or the staged run does not pass
|
||||
`gstack-artifact validate`. `--from` naming a run's collection restores the
|
||||
newest generation that verifies, skipping corrupt newer ones; a root holding
|
||||
several runs must name one. 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.
|
||||
**Fix.** `gstack-autoplan checkpoints --from <dir>` lists every generation
|
||||
with `hash=ok` or `hash=bad (<reason>)`; restore an intact one by passing its
|
||||
directory. With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, every command that
|
||||
leaves the run at rest publishes a new generation and keeps the previous one.
|
||||
|
||||
<a id="checkpoint-overlap"></a>
|
||||
### `CHECKPOINT_OVERLAP`
|
||||
|
||||
**Meaning.** The checkpoint directory for this run and the run directory (or,
|
||||
on restore, the checkpoint and `--out` or its staging directory) are the same
|
||||
directory or one contains the other, after resolving symlinks. Copying one
|
||||
into the other would replace or delete the live run, so nothing was touched.
|
||||
A checkpoint refusal after a committed command prints `CHECKPOINT: failed …`
|
||||
on stderr and leaves that command's exit code alone.
|
||||
|
||||
**Fix.** Point `GSTACK_AUTOPLAN_CHECKPOINT_DIR` (or `--from` / `--out`) at a
|
||||
directory outside the run, for example the private project-user Drive folder.
|
||||
|
||||
<a id="checkpoint-target-exists"></a>
|
||||
### `CHECKPOINT_TARGET_EXISTS`
|
||||
|
||||
+8
-2
@@ -239,8 +239,14 @@ the plan.
|
||||
consumption steps above apply unchanged. `$AP status --out <dir>` and
|
||||
`resume` work after the run directory moves (paths are re-rooted; journal
|
||||
paths are run-relative). With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, a run
|
||||
at rest is copied, hash-listed, to `<dir>/<run>/`; `$AP resume --out <new
|
||||
dir> --from <dir>/<run>` verifies and restores it (`CHECKPOINT_INVALID`).
|
||||
at rest publishes an immutable, hash-listed generation under
|
||||
`<dir>/<run>/generations/` and switches `current.json` to it (the previous
|
||||
generation is kept; a stale copy never replaces a newer one; a directory
|
||||
overlapping the run is refused with `CHECKPOINT_OVERLAP`; a failure is a
|
||||
`CHECKPOINT: failed` warning that leaves the command's exit code alone).
|
||||
`$AP checkpoints --from <dir>` lists generations with their hash status, and
|
||||
`$AP resume --out <new dir> --from <dir>/<run>` restores the newest valid
|
||||
one after validating the staged run (`CHECKPOINT_INVALID`).
|
||||
|
||||
Concurrency and recovery: every state write is a locked read-modify-write
|
||||
(`run.lock`; a second writer sees `RUN_LOCKED`), attempts are journaled to
|
||||
|
||||
+185
-72
@@ -2,26 +2,71 @@
|
||||
* 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.
|
||||
* publishes one immutable generation under `<dir>/<run>/generations/<name>/`:
|
||||
* `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`. `current.json` then switches to it with one rename; the
|
||||
* previous generation stays until the switch commits, so an interrupted
|
||||
* publish leaves the last checkpoint current. A generation never replaces a
|
||||
* newer one (higher run revision). Publication and restore hold the
|
||||
* collection's `.lock`; restore also holds `<out>.lock`.
|
||||
*
|
||||
* Restore picks the named generation, or the newest generation that verifies
|
||||
* when `--from` names the collection (or a root holding one run). It refuses
|
||||
* overlapping source and target paths (symlink aliases included), any listed
|
||||
* file whose real path leaves the generation, and any hash mismatch; it
|
||||
* validates the staged run (gstack-artifact's validateRun) before publishing
|
||||
* it to `--out` with one rename, so a refusal leaves `--out` absent.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { sha256File } from './headless-artifacts';
|
||||
import { sha256File, validateRun } from './headless-artifacts';
|
||||
import { FILES, RunError, journal, now, readState, type RunState } from './autoplan-state';
|
||||
|
||||
const MANIFEST = 'checkpoint.json';
|
||||
const CURRENT = 'current.json';
|
||||
const GENERATIONS = 'generations';
|
||||
const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'incomplete'];
|
||||
const FOREIGN_LOCK_STALE_MS = 10 * 60_000;
|
||||
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 };
|
||||
}
|
||||
export interface Generation { dir: string; manifest?: CheckpointManifest; problem?: string }
|
||||
|
||||
const inside = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); };
|
||||
const overlaps = (a: string, b: string) => a === b || inside(a, b) || inside(b, a);
|
||||
/** The real path of `p`, resolving symlinks through its deepest existing ancestor. */
|
||||
function canonical(p: string): string {
|
||||
let cur = path.resolve(p);
|
||||
const rest: string[] = [];
|
||||
while (!fs.existsSync(cur) && path.dirname(cur) !== cur) { rest.unshift(path.basename(cur)); cur = path.dirname(cur); }
|
||||
return path.join(fs.realpathSync(cur), ...rest);
|
||||
}
|
||||
function refuseOverlap(what: string, a: string, b: string): void {
|
||||
const ca = canonical(a), cb = canonical(b);
|
||||
if (overlaps(ca, cb)) throw new RunError('CHECKPOINT_OVERLAP', `${what}: ${a} (${ca}) and ${b} (${cb}) are the same directory or one contains the other`, 3);
|
||||
}
|
||||
|
||||
function exclusive<T>(lock: string, fn: () => T): T {
|
||||
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
||||
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: now() });
|
||||
for (let i = 0; ; i++) {
|
||||
try { fs.writeFileSync(lock, mine, { flag: 'wx' }); break; } catch (e: any) {
|
||||
if (e.code !== 'EEXIST') throw e;
|
||||
let owner: { pid?: number; host?: string; at?: string } = {};
|
||||
try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch { /* being written */ }
|
||||
let alive = true;
|
||||
if (owner.host === os.hostname() && typeof owner.pid === 'number') { try { process.kill(owner.pid, 0); } catch (k: any) { alive = k.code === 'EPERM'; } }
|
||||
else if (owner.host && owner.at) alive = Date.now() - Date.parse(owner.at) < FOREIGN_LOCK_STALE_MS;
|
||||
if (alive || i > 3) throw new RunError('RUN_LOCKED', `${lock} is held by pid ${owner.pid ?? '?'} on ${owner.host ?? '?'} since ${owner.at ?? '?'}`, 3);
|
||||
fs.rmSync(lock, { force: true });
|
||||
}
|
||||
}
|
||||
try { return fn(); } finally { try { if (fs.readFileSync(lock, 'utf8') === mine) fs.unlinkSync(lock); } catch { /* gone */ } }
|
||||
}
|
||||
|
||||
function runFiles(dir: string, rel = ''): string[] {
|
||||
return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => {
|
||||
@@ -30,86 +75,154 @@ function runFiles(dir: string, rel = ''): string[] {
|
||||
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));
|
||||
const writeAtomic = (file: string, text: string) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
|
||||
|
||||
function restoreIndex(m: CheckpointManifest, target: string): string {
|
||||
function restoreIndex(m: CheckpointManifest, collection: 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');
|
||||
`Current generation written ${m.at} on ${m.host} at status=${m.status}, gate_rev=${m.gate_rev}, revision=${m.revision}; ${Object.keys(m.files).length} files, each listed with its sha256 in generations/<name>/checkpoint.json.`, '',
|
||||
'List the generations and their hash status, then restore the newest valid one on any machine (the target directory must not hold a run):', '',
|
||||
` gstack-autoplan checkpoints --from ${collection}`,
|
||||
` gstack-autoplan resume --out <new run dir> --from ${collection}`, '',
|
||||
'resume verifies every hash and the staged run, 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 plan-input/ 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. */
|
||||
/** The newest run revision any generation in the collection records (unreadable generations count as nothing). */
|
||||
function newestRevision(collection: string): number {
|
||||
const dir = path.join(collection, GENERATIONS);
|
||||
if (!fs.existsSync(dir)) return -1;
|
||||
return Math.max(-1, ...fs.readdirSync(dir).map(g => { try { return Number(JSON.parse(fs.readFileSync(path.join(dir, g, MANIFEST), 'utf8')).revision); } catch { return -1; } }).filter(Number.isFinite));
|
||||
}
|
||||
|
||||
/** Publish a generation 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}`;
|
||||
const collection = path.join(path.resolve(root), state.run);
|
||||
refuseOverlap('the checkpoint directory overlaps the run directory', collection, state.out);
|
||||
return exclusive(path.join(collection, '.lock'), () => {
|
||||
const newest = newestRevision(collection);
|
||||
if (newest > state.revision) return `CHECKPOINT: skipped ${collection}: it holds a newer checkpoint (revision ${newest} > ${state.revision}); nothing replaced`;
|
||||
const name = `g${String(state.revision).padStart(6, '0')}-${now().replace(/[-:.TZ]/g, '')}-${process.pid}`;
|
||||
const tmp = path.join(collection, `.tmp-${name}`);
|
||||
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, path.resolve(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, collection));
|
||||
fs.writeFileSync(path.join(tmp, MANIFEST), JSON.stringify(m, null, 2) + '\n');
|
||||
fs.mkdirSync(path.join(collection, GENERATIONS), { recursive: true });
|
||||
fs.renameSync(tmp, path.join(collection, GENERATIONS, name));
|
||||
if (env.GSTACK_AUTOPLAN_FAULT === 'checkpoint-before-pointer') throw new Error('fault injected: checkpoint interrupted before switching current.json (GSTACK_AUTOPLAN_FAULT, tests only)');
|
||||
let previous: string | undefined;
|
||||
try { previous = JSON.parse(fs.readFileSync(path.join(collection, CURRENT), 'utf8')).generation; } catch { /* first generation */ }
|
||||
writeAtomic(path.join(collection, CURRENT), JSON.stringify({ generation: name, run: m.run, status: m.status, gate_rev: m.gate_rev, revision: m.revision, at: m.at }, null, 2) + '\n');
|
||||
writeAtomic(path.join(collection, 'restore.md'), restoreIndex(m, collection));
|
||||
for (const g of fs.readdirSync(path.join(collection, GENERATIONS))) if (g !== name && g !== previous) fs.rmSync(path.join(collection, GENERATIONS, g), { recursive: true, force: true });
|
||||
for (const e of fs.readdirSync(collection)) if (e.startsWith('.tmp-')) fs.rmSync(path.join(collection, e), { recursive: true, force: true });
|
||||
return `CHECKPOINT: ${path.join(collection, GENERATIONS, name)} status=${m.status} gate_rev=${m.gate_rev} revision=${m.revision} 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. */
|
||||
/** Verify one generation without throwing: manifest shape, containment by real path, regular files, hashes. */
|
||||
export function verifyGeneration(dir: string): Generation {
|
||||
let m: CheckpointManifest;
|
||||
try { m = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), 'utf8')); } catch (e) { return { dir, problem: `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]) return { dir, manifest: m, problem: `${MANIFEST} is not a schema_version 1 checkpoint with run.json listed` };
|
||||
const check = (rel: string, sha: string, base: string): string | undefined => {
|
||||
if (typeof rel !== 'string' || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return `${rel} escapes the checkpoint`;
|
||||
const file = path.join(base, rel);
|
||||
let st: fs.Stats;
|
||||
try { st = fs.lstatSync(file); } catch { return `${rel} is listed but missing`; }
|
||||
if (!st.isFile()) return `${rel} is not a regular file`;
|
||||
if (!inside(fs.realpathSync(base), fs.realpathSync(file))) return `${rel} escapes the checkpoint through a symlinked directory`;
|
||||
if (sha256File(file) !== sha) return `${rel} does not match its sha256 in ${MANIFEST}`;
|
||||
return undefined;
|
||||
};
|
||||
for (const [rel, sha] of Object.entries(m.files)) { const problem = check(rel, sha, path.join(dir, 'run')); if (problem) return { dir, manifest: m, problem }; }
|
||||
if (m.plan_input) { const problem = check(m.plan_input.file, m.plan_input.sha256, dir); if (problem) return { dir, manifest: m, problem }; }
|
||||
return { dir, manifest: m };
|
||||
}
|
||||
|
||||
/** Every generation `from` names: one generation, a run's collection, or a root of collections; newest first. */
|
||||
export function listGenerations(from: string): Generation[] {
|
||||
const src = path.resolve(from);
|
||||
if (fs.existsSync(path.join(src, MANIFEST))) return [verifyGeneration(src)];
|
||||
const collections = fs.existsSync(path.join(src, GENERATIONS)) ? [src]
|
||||
: fs.existsSync(src) ? fs.readdirSync(src).map(e => path.join(src, e)).filter(c => fs.existsSync(path.join(c, GENERATIONS))) : [];
|
||||
const order = (g: Generation) => [g.manifest?.revision ?? -1, Date.parse(g.manifest?.at ?? '') || 0] as const;
|
||||
return collections.flatMap(c => fs.readdirSync(path.join(c, GENERATIONS)).map(g => verifyGeneration(path.join(c, GENERATIONS, g))))
|
||||
.sort((a, b) => order(b)[0] - order(a)[0] || order(b)[1] - order(a)[1]);
|
||||
}
|
||||
|
||||
/** `gstack-autoplan checkpoints --from <dir>`: one line per generation, newest first. */
|
||||
export function checkpointLines(from: string): string[] {
|
||||
const gens = listGenerations(from);
|
||||
if (!gens.length) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)}: no checkpoint generations found`, 3);
|
||||
return gens.map(g => {
|
||||
const m = g.manifest;
|
||||
const collection = path.dirname(path.dirname(g.dir));
|
||||
let current = false;
|
||||
try { current = JSON.parse(fs.readFileSync(path.join(collection, CURRENT), 'utf8')).generation === path.basename(g.dir); } catch { /* no pointer */ }
|
||||
return `CHECKPOINT: ${g.dir} run=${m?.run ?? '?'} gate_rev=${m?.gate_rev ?? '?'} status=${m?.status ?? '?'} revision=${m?.revision ?? '?'} at=${m?.at ?? '?'} hash=${g.problem ? `bad (${g.problem})` : 'ok'}${current ? ' current' : ''}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** The generation to restore: the one named, or the newest that verifies in a collection or a root holding one run. */
|
||||
function chooseGeneration(from: string): Generation {
|
||||
const gens = listGenerations(from);
|
||||
const runs = [...new Set(gens.map(g => g.manifest?.run).filter(Boolean))];
|
||||
if (runs.length > 1) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)} holds checkpoints of ${runs.length} runs (${runs.join(', ')}); pass --from <dir>/<run>`, 3);
|
||||
const valid = gens.find(g => !g.problem);
|
||||
if (!valid) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)}: ${gens.length ? gens.map(g => `${path.basename(g.dir)}: ${g.problem}`).join('; ') : 'no checkpoint generations found'}`, 3);
|
||||
return valid;
|
||||
}
|
||||
|
||||
/** Verify a checkpoint and publish it as the run directory `out`. Nothing is written to `out` unless every check passes. */
|
||||
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 {
|
||||
refuseOverlap('the checkpoint and --out', src, target);
|
||||
refuseOverlap('the checkpoint and the restore staging directory', src, staging);
|
||||
const collectionLock = fs.existsSync(path.join(src, MANIFEST)) ? path.join(path.dirname(path.dirname(src)), '.lock') : path.join(src, '.lock');
|
||||
return exclusive(`${target}.lock`, () => exclusive(collectionLock, () => {
|
||||
if (fs.existsSync(target) && fs.readdirSync(target).length) throw new RunError('CHECKPOINT_TARGET_EXISTS', `${target} is not empty`, 3);
|
||||
const gen = chooseGeneration(src);
|
||||
const m = gen.manifest!;
|
||||
const skipped = listGenerations(src).filter(g => g.problem && (g.manifest?.revision ?? -1) >= m.revision).map(g => `CHECKPOINT: skipped ${g.dir} (${g.problem})`);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
try {
|
||||
for (const rel of Object.keys(m.files)) copy(path.join(gen.dir, 'run', rel), path.join(staging, rel));
|
||||
for (const [rel, sha] of Object.entries(m.files)) if (sha256File(path.join(staging, rel)) !== sha) throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: ${rel} changed while it was copied`, 3);
|
||||
const lines: string[] = [];
|
||||
const runFile = path.join(staging, FILES.run);
|
||||
let state: RunState;
|
||||
try { state = JSON.parse(fs.readFileSync(runFile, 'utf8')); } catch (e) { throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: run.json does not parse (${(e as Error).message})`, 3); }
|
||||
if (state.run !== m.run || typeof state.out !== 'string' || !state.phases || typeof state.phases !== 'object') throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: run.json is not the run state of ${m.run}`, 3);
|
||||
if (m.plan_input && !fs.existsSync(m.plan_input.original)) {
|
||||
copy(path.join(gen.dir, m.plan_input.file), path.join(staging, m.plan_input.file));
|
||||
state.plan_input = path.join(state.out, m.plan_input.file);
|
||||
if (!fs.existsSync(state.plan_source)) state.plan_source = state.plan_input;
|
||||
fs.writeFileSync(runFile, JSON.stringify(state, null, 2) + '\n');
|
||||
lines.push(`PLAN_INPUT: ${m.plan_input.original} is missing; restored the checkpoint copy to ${path.join(target, m.plan_input.file)}`);
|
||||
} else if (m.plan_input && 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`);
|
||||
const v = validateRun(runFile);
|
||||
if (!v.valid) throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: the staged run does not validate: ${v.errors.slice(0, 3).map(e => `${e.code} ${e.path}: ${e.message}`).join('; ')}`, 3);
|
||||
if (fs.existsSync(target)) fs.rmdirSync(target);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.renameSync(staging, target);
|
||||
readState(target);
|
||||
journal(target, { event: 'restored', from: gen.dir, checkpoint_at: m.at, checkpoint_host: m.host, revision: m.revision, host: os.hostname(), files: Object.keys(m.files).length });
|
||||
return [...skipped, `RESTORED: run=${m.run} from=${gen.dir} status=${m.status} gate_rev=${m.gate_rev} revision=${m.revision} files=${Object.keys(m.files).length} (written ${m.at} on ${m.host})`, ...lines];
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}));
|
||||
}
|
||||
+16
-6
@@ -19,8 +19,8 @@ import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { exportRun } from './autoplan-export';
|
||||
import { driftLine } from './autoplan-plan-text';
|
||||
import { consensusRecord, reconcile, renderConsensus, SETTLEABLE_CHANGES, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile';
|
||||
import { driftLine, reviewHash } from './autoplan-plan-text';
|
||||
import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHANGES, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile';
|
||||
import type { Phase } from './autoplan-prompts';
|
||||
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state';
|
||||
import { phaseClose } from './autoplan-timing';
|
||||
@@ -71,7 +71,6 @@ const CONTENT_SCOPE: Record<string, string> = { ceo: 'scope', design: 'design',
|
||||
const optionHash = (options: GateOption[]) => createHash('sha256').update(JSON.stringify(options.map(o => [o.key, o.text]))).digest('hex').slice(0, 16);
|
||||
const KEEP = (text: string): GateOption => ({ key: 'a', text, changes_plan: false });
|
||||
|
||||
const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
|
||||
/**
|
||||
* A user challenge both voices raised recommends the change only with an
|
||||
@@ -88,7 +87,7 @@ export function challengeDefault(row: ReconciliationRow, n: FindingRow | undefin
|
||||
: nf?.resolution || of.resolution ? ` (native: ${nf?.resolution ?? 'no remedy stated'}; outside: ${of.resolution ?? 'no remedy stated'})` : '';
|
||||
const text = `accept the change both reviewers recommend${remedies}`;
|
||||
const keep = (basis: string) => ({ recommended: 'a' as const, basis: `plan direction; ${basis}`, text });
|
||||
if (row.disposition === 'unverified') return keep('same location, agreement unverified');
|
||||
if (row.disposition === 'unverified') return keep(row.resolution.startsWith('same title, a remedy is missing') ? 'a remedy is missing' : row.resolution.startsWith('same title') ? 'remedies differ; no shared proposal' : 'same location, agreement unverified');
|
||||
if (row.disposition !== 'confirmed') return keep('voices disagree');
|
||||
if (!nf?.changes || !of.changes) return keep('no shared changes value');
|
||||
if (nf.changes !== of.changes) return keep('voices name different changes');
|
||||
@@ -147,7 +146,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
|
||||
}
|
||||
}
|
||||
items.push(planApproval(state, items));
|
||||
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate' };
|
||||
const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0 };
|
||||
const problems = validateGateList(list);
|
||||
if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`);
|
||||
return list;
|
||||
@@ -237,6 +236,15 @@ export function applyGuidance(a: Pick<Amendment, 'changes' | 'item'>): string {
|
||||
return a.changes && SETTLEABLE_CHANGES.includes(a.changes) ? `no plan edit needed: revise --settle ${a.item}` : 'edit the plan, then revise';
|
||||
}
|
||||
|
||||
/** The revise steps that discharge the pending amendments: settle the packaging/ordering changes first, then edit for the rest. */
|
||||
export function amendmentSteps(state: RunState, out: string): string {
|
||||
const pending = (state.amendments ?? []).filter(a => a.status === 'pending');
|
||||
const settle = pending.filter(a => applyGuidance(a).startsWith('no plan edit')).map(a => a.item ?? a.decision);
|
||||
const edit = pending.filter(a => !applyGuidance(a).startsWith('no plan edit')).map(a => a.decision);
|
||||
return [settle.length ? `gstack-autoplan revise --out ${out} --settle ${settle.join(',')}` : '',
|
||||
edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : ''].filter(Boolean).join('; then ');
|
||||
}
|
||||
|
||||
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */
|
||||
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean {
|
||||
const owner = parseReply(ownerReply, list, gateRev, prior);
|
||||
@@ -270,6 +278,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
|
||||
if (!parsed.valid) throw new RunError('GATE_REPLY_UNPARSED', parsed.error === 'GATE_EMPTY' ? 'empty reply' : `unparsed: ${parsed.unparsed.join(', ')}`);
|
||||
const currentHash = recHash(list);
|
||||
if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3);
|
||||
if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit <id><option> tokens`, 3);
|
||||
if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
|
||||
const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
|
||||
const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
|
||||
@@ -310,10 +319,11 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
|
||||
lines.push('REJECTED: run.json.status=incomplete');
|
||||
return { state, applied: true, pending: [], reopened: [], outcome: 'rejected', lines };
|
||||
}
|
||||
const acceptedPlan = changing.length ? reviewHash(fs.readFileSync(state.active_plan, 'utf8')) : '';
|
||||
for (const c of changing) {
|
||||
const item = itemOf(c.id);
|
||||
const amendment: Amendment = { decision: decisionId(state.run, gateRev, c.id), title: item.title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt,
|
||||
item: c.id, ...(item.changes ? { changes: item.changes } : {}), ...(item.phase ? { phase: item.phase } : {}) };
|
||||
item: c.id, ...(item.changes ? { changes: item.changes } : {}), ...(item.phase ? { phase: item.phase } : {}), plan_sha256: acceptedPlan };
|
||||
(state.amendments ??= []).push(amendment);
|
||||
journalRow(state.out, { event: 'amendment_pending', decision: amendment.decision, gate_rev: gateRev, ...(item.changes ? { changes: item.changes } : {}) });
|
||||
lines.push(`APPLY: ${amendment.decision} ${item.title} -> ${amendment.text}; ${applyGuidance(amendment)}`);
|
||||
|
||||
+24
-10
@@ -209,13 +209,26 @@ function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
return inter / (a.size + b.size - inter);
|
||||
}
|
||||
const level = (s: Severity) => SEVERITIES.indexOf(s);
|
||||
/** A remedy compared as words: case, punctuation and spacing ignored. */
|
||||
export const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
const NEGATING = /\b(keep|leave|retain|not|no|never|without|avoid|remove|drop|revert|skip)\b|n't\b/;
|
||||
/** Opposite remedies: they name the same thing and exactly one of them keeps, drops or negates it. */
|
||||
function contradictory(a: string, b: string): boolean {
|
||||
const ka = remedyKey(a), kb = remedyKey(b);
|
||||
if (NEGATING.test(ka) === NEGATING.test(kb)) return false;
|
||||
const words = (k: string) => new Set(k.split(' ').filter(t => t.length > 3 && !NEGATING.test(t)));
|
||||
const wb = words(kb);
|
||||
return [...words(ka)].some(t => wb.has(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural reconciliation. Findings associate by a title overlap of at least
|
||||
* half the words, or by file:line (±3 lines). A title association is
|
||||
* `confirmed` unless severities differ by two or more levels (`disagree`); a
|
||||
* location-only association is `unverified` (same location, agreement
|
||||
* unverified: two findings near one line are not one finding); else `new`.
|
||||
* half the words, or by file:line (±3 lines). Association alone never
|
||||
* confirms: a title association is `confirmed` only when both voices state the
|
||||
* same remedy, `disagree` when severities differ by two or more levels or the
|
||||
* remedies contradict (one keeps, drops or negates what the other changes),
|
||||
* and otherwise `unverified` (remedies differ or one is missing); a
|
||||
* location-only association is `unverified`; else `new`.
|
||||
* Each native finding is matched at most once. Overrides (the parent's
|
||||
* semantic reconciliation, with an optional shared `proposal`) replace the
|
||||
* structural row for their outside id and must name ids from these two lists.
|
||||
@@ -263,12 +276,13 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
|
||||
rows.push({ outside_id: o.id, native_id: match.id, disposition: 'unverified', resolution: `same location, agreement unverified (${where}): ${match.title}`, basis, severity: { outside: o.severity, native: match.severity } });
|
||||
continue;
|
||||
}
|
||||
const delta = Math.abs(level(match.severity) - level(o.severity));
|
||||
rows.push({
|
||||
outside_id: o.id, native_id: match.id, disposition: delta >= 2 ? 'disagree' : 'confirmed',
|
||||
resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${where})` : `both voices (${where}): ${match.title}`,
|
||||
basis, severity: { outside: o.severity, native: match.severity },
|
||||
});
|
||||
const severity = { outside: o.severity, native: match.severity };
|
||||
const pair = (disposition: ReconciliationDisposition, resolution: string) => rows.push({ outside_id: o.id, native_id: match!.id, disposition, resolution, basis, severity });
|
||||
if (Math.abs(level(match.severity) - level(o.severity)) >= 2) { pair('disagree', `severity: native ${match.severity}, outside ${o.severity} (${where})`); continue; }
|
||||
if (!match.resolution || !o.resolution) { pair('unverified', `same title, a remedy is missing (${where}): ${match.title}`); continue; }
|
||||
if (contradictory(match.resolution, o.resolution)) { pair('disagree', `remedies conflict (${where}): native "${match.resolution}", outside "${o.resolution}"`); continue; }
|
||||
if (remedyKey(match.resolution) !== remedyKey(o.resolution)) { pair('unverified', `same title, remedies differ (${where}): ${match.title}`); continue; }
|
||||
pair('confirmed', `both voices, same remedy (${where}): ${match.title}`);
|
||||
}
|
||||
return { rows, native_only: native.filter(n => !used.has(n.id)).map(n => n.id), errors };
|
||||
}
|
||||
|
||||
+18
-11
@@ -57,28 +57,34 @@ function resolveIds(state: RunState, ids: string[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* `--settle` evidence: a packaging/ordering change on a plan whose reviewed Implementation plan is unchanged (the
|
||||
* active plan and the owner's plan file both hash to the snapshot the phases reviewed). A real change never settles.
|
||||
* `--settle` evidence: a packaging/ordering change settles on the plan the run already reviews, never on an
|
||||
* unrevised edit. Alone, the owner's plan file must hash to the active plan, and the active plan must be the one
|
||||
* the owner accepted the change on or one a later `revise` recorded (plan_revised). With `--plan` (beside
|
||||
* `--incorporates`), it settles on that revision's hash, which the reopened phases then review.
|
||||
*/
|
||||
function settleEvidence(state: RunState, ids: string[]): { plan_sha256: string; changes: Record<string, string> } {
|
||||
const reviewed = state.phase_order.map(p => state.phases[p]?.snapshot).find(Boolean);
|
||||
function settleEvidence(state: RunState, ids: string[], revised?: string): { plan_sha256: string; changes: Record<string, string> } {
|
||||
const active = reviewHash(fs.readFileSync(state.active_plan, 'utf8'));
|
||||
let input = active;
|
||||
try { if (state.plan_input && fs.existsSync(state.plan_input)) input = reviewHash(revisionText(state, state.plan_input)); } catch { input = ''; }
|
||||
const changes: Record<string, string> = {};
|
||||
for (const id of ids) {
|
||||
const a = pendingAmendments(state).find(x => x.decision === id)!;
|
||||
if (!a.changes || !SETTLEABLE_CHANGES.includes(a.changes)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `${id} changes ${a.changes ?? 'nothing named'}; only packaging or ordering changes settle without a plan edit`, 3);
|
||||
changes[id] = a.changes;
|
||||
if (revised || !a.plan_sha256) continue;
|
||||
const recorded = [a.plan_sha256, ...readJournal(state.out).filter(r => r.event === 'plan_revised' && r.ts >= a.at).map(r => r.new_plan_sha256 as string)];
|
||||
if (!recorded.includes(active)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the active plan ${active.slice(0, 12)} is neither the plan ${id} was accepted on (${a.plan_sha256.slice(0, 12)}) nor one revise recorded; revise --plan instead`, 3);
|
||||
}
|
||||
if (!reviewed || active !== reviewed || input !== reviewed) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the reviewed plan changed (reviewed ${String(reviewed).slice(0, 12)}, active ${active.slice(0, 12)}, plan file ${input.slice(0, 12) || 'unreadable'}); revise --plan instead`, 3);
|
||||
return { plan_sha256: reviewed, changes };
|
||||
if (revised) return { plan_sha256: revised, changes };
|
||||
let input = '';
|
||||
try { input = state.plan_input && fs.existsSync(state.plan_input) ? reviewHash(revisionText(state, state.plan_input)) : active; } catch { /* unreadable: refused below */ }
|
||||
if (input !== active) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the plan file has edits the run has not reviewed (active ${active.slice(0, 12)}, plan file ${input.slice(0, 12) || 'unreadable'}); revise --plan <file> --incorporates <ids> --settle ${ids.join(',')} instead`, 3);
|
||||
return { plan_sha256: active, changes };
|
||||
}
|
||||
|
||||
/** Every accepted change is discharged and nothing reopened: the answered gate stands and the run is complete. */
|
||||
/** Every accepted change is discharged: the answered gate stands (complete) unless a revise reopened phases still to rerun. */
|
||||
function finishAmendments(state: RunState, at: string): string[] {
|
||||
if (state.status !== 'amend_pending' || pendingAmendments(state).length) return [];
|
||||
journal(state.out, { event: 'amendments_settled', gate_rev: state.gate_rev ?? null });
|
||||
if (state.phase_order.some(p => !state.phases[p]?.closed_at)) { state.status = 'running'; return []; }
|
||||
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
|
||||
}
|
||||
|
||||
@@ -92,9 +98,9 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
|
||||
for (const id of [...incorporates, ...withdraw, ...settle]) {
|
||||
if (!pending.includes(id)) throw new RunError('AMENDMENT_UNKNOWN', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3);
|
||||
}
|
||||
const evidence = settle.length ? settleEvidence(state, settle) : undefined;
|
||||
const at = now();
|
||||
if (!o.plan) {
|
||||
const evidence = settle.length ? settleEvidence(state, settle) : undefined;
|
||||
for (const row of settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' })) journal(out, row);
|
||||
for (const row of settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 })) journal(out, { ...row, changes: evidence!.changes[row.decision as string] });
|
||||
const lines = [...settle.map(id => `SETTLED: ${id} (${evidence!.changes[id]}; plan ${evidence!.plan_sha256.slice(0, 12)} unchanged; nothing reopens)`), amendmentLine(state), ...finishAmendments(state, at)];
|
||||
@@ -109,7 +115,8 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
|
||||
if (incorporates.length && newHash === oldHash) {
|
||||
throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${incorporates.join(', ')} needs the edit in ${file}`, 3);
|
||||
}
|
||||
if (settle.length && newHash !== oldHash) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the revised plan changes the Implementation plan (${oldHash.slice(0, 12)} -> ${newHash.slice(0, 12)}); incorporate ${settle.join(', ')} instead of settling`, 3);
|
||||
if (settle.length && newHash !== oldHash && !incorporates.length) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the revised plan changes the Implementation plan (${oldHash.slice(0, 12)} -> ${newHash.slice(0, 12)}) and incorporates nothing; incorporate ${settle.join(', ')} instead of settling`, 3);
|
||||
const evidence = settle.length ? settleEvidence(state, settle, newHash) : undefined;
|
||||
const k = (state.plan_revision ?? 0) + 1;
|
||||
const staging = path.join(out, `.revise-v${k}`);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
|
||||
import { applyGuidance, closePhase, finishGate } from './autoplan-gate';
|
||||
import { amendmentSteps, applyGuidance, closePhase, finishGate } from './autoplan-gate';
|
||||
import { GSTACK_VERSION } from './autoplan-migrate';
|
||||
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
|
||||
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
|
||||
@@ -179,7 +179,7 @@ export function nextStep(o: NextOptions): NextResult {
|
||||
const pending = pendingAmendments(state).map(a => a.decision);
|
||||
if (pending.length) {
|
||||
const each = pendingAmendments(state).map(a => `APPLY ${a.item ?? a.decision}: ${applyGuidance(a)}`).join('; ');
|
||||
throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')} (${each}); edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --settle <id> for a packaging/ordering change, --withdraw <id>)`, 3);
|
||||
throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')} (${each}); ${amendmentSteps(state, state.out)} (or --withdraw <id>)`, 3);
|
||||
}
|
||||
const phase = currentPhase(state);
|
||||
if (!phase) { finishGate(state); writeState(state); return result; }
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface Amendment {
|
||||
decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn' | 'settled'; at: string; settled_at?: string;
|
||||
/** The gate item's `changes` and phase: packaging/ordering changes settle without a plan edit (`revise --settle`). */
|
||||
changes?: string; phase?: string; item?: string;
|
||||
/** The reviewed Implementation-plan hash of the active plan when the owner accepted it: `--settle` evidence. */
|
||||
plan_sha256?: string;
|
||||
}
|
||||
export interface PhaseState {
|
||||
snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string;
|
||||
@@ -81,7 +83,7 @@ export function readState(out: string): RunState {
|
||||
state.out = path.resolve(out);
|
||||
if (previous && previous !== state.out) {
|
||||
const reroot = (p: string | undefined) => (p && p.startsWith(previous + path.sep) ? path.join(state.out, path.relative(previous, p)) : p);
|
||||
state.active_plan = reroot(state.active_plan)!; state.restore = reroot(state.restore)!;
|
||||
state.active_plan = reroot(state.active_plan)!; state.restore = reroot(state.restore)!; state.plan_source = reroot(state.plan_source)!; state.plan_input = reroot(state.plan_input);
|
||||
for (const ps of Object.values(state.phases)) {
|
||||
ps.snapshot_dir = reroot(ps.snapshot_dir); ps.snapshot_path = reroot(ps.snapshot_path); ps.methodology = reroot(ps.methodology);
|
||||
if (ps.prompts) for (const k of Object.keys(ps.prompts) as Array<keyof typeof ps.prompts>) ps.prompts[k] = reroot(ps.prompts[k])!;
|
||||
|
||||
+10
-5
@@ -533,18 +533,23 @@ export const RESULT_CODES = {
|
||||
},
|
||||
AMENDMENT_NOT_SETTLEABLE: {
|
||||
anchor: 'amendment-not-settleable',
|
||||
summary: '`revise --settle` named an accepted change that needs a plan edit (its item changes scope, cost or direction, or names no packaging/ordering change), or the reviewed plan changed since the gate',
|
||||
fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan',
|
||||
summary: '`revise --settle` named an accepted change that needs a plan edit (its item changes scope, cost or direction, or names no packaging/ordering change), or the plan has edits no revise has recorded',
|
||||
fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>` (add `--settle <id>` to settle packaging/ordering changes in the same call); settle alone only on the plan the run already reviews',
|
||||
},
|
||||
GATE_REC_STALE: {
|
||||
anchor: 'gate-rec-stale',
|
||||
summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes',
|
||||
summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes, or the gate predates `rec` (a gate.json without rec_rev, rendered when `all` left changes pending)',
|
||||
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',
|
||||
summary: 'no autoplan checkpoint generation can be restored: a manifest is unreadable, a listed file is missing, escapes the generation (by path or symlink) or does not match its sha256, or the staged run does not validate',
|
||||
fix: 'run `gstack-autoplan checkpoints --from <dir>` to see each generation\'s hash status and restore an intact one; nothing was written to --out',
|
||||
},
|
||||
CHECKPOINT_OVERLAP: {
|
||||
anchor: 'checkpoint-overlap',
|
||||
summary: 'the checkpoint directory and the run directory (or the restore target) are the same directory or one contains the other, symlinks resolved',
|
||||
fix: 'point GSTACK_AUTOPLAN_CHECKPOINT_DIR (or --from / --out) at a directory outside the run; nothing was moved or deleted',
|
||||
},
|
||||
CHECKPOINT_TARGET_EXISTS: {
|
||||
anchor: 'checkpoint-target-exists',
|
||||
|
||||
@@ -29,7 +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' },
|
||||
{ cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them' },
|
||||
];
|
||||
|
||||
/** The reply grammar `gstack-gate` parses, one form per line. */
|
||||
|
||||
@@ -33,7 +33,7 @@ function gatedRun(name: string): string {
|
||||
const ids = attemptsOf(out, 'ceo');
|
||||
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0);
|
||||
const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'gpt-5.5', '--attempt', ids.outside]);
|
||||
expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=1 disagree=1 new=1/);
|
||||
expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=0 unverified=1 disagree=1 new=1/);
|
||||
const eng = runPhase(out, 'eng');
|
||||
expect(eng.outside.stdout).toContain('status=gate_pending');
|
||||
return out;
|
||||
|
||||
@@ -1,90 +1,183 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
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();
|
||||
|
||||
function checkpointed(name: string): { dir: string; out: string; cp: string; run: string; e: Record<string, string> } {
|
||||
/** A small run brought to gate_pending with the checkpoint env on for the last submit. */
|
||||
function checkpointed(name: string): { dir: string; out: string; root: string; collection: string; run: string; e: Record<string, string> } {
|
||||
const dir = path.join(TMP, name);
|
||||
const cpRoot = path.join(dir, 'drive');
|
||||
const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: cpRoot });
|
||||
const root = path.join(dir, 'drive');
|
||||
const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root });
|
||||
const out = startSmallRun(dir);
|
||||
runPhase(out, 'ceo');
|
||||
const next = autoplan(['next', '--out', out], { env: e });
|
||||
expect(next.stderr).not.toContain('CHECKPOINT:');
|
||||
expect(autoplan(['next', '--out', out], { env: e }).stderr).not.toContain('CHECKPOINT:');
|
||||
for (const voice of ['native', 'outside'] as const) review(path.join(out, `eng-${voice}-prompt.md`));
|
||||
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 };
|
||||
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, resume, answer and export: validate passes', () => {
|
||||
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 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 });
|
||||
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.cp, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out <new run dir> --from ${c.cp}`);
|
||||
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 moved = path.join(c.dir, 'elsewhere', 'run');
|
||||
const resumed = autoplan(['resume', '--out', moved, '--from', c.cp], { env: c.e });
|
||||
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 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(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);
|
||||
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);
|
||||
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('a tampered file, an escaping path and a non-empty target are refused and write nothing', () => {
|
||||
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', c.cp]);
|
||||
expect(busy.status).toBe(3);
|
||||
const busy = autoplan(['resume', '--out', nonEmpty, '--from', gen]);
|
||||
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);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,50 @@ describe('record-only changes settle without a plan edit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed amendments: a scope change and a packaging change', () => {
|
||||
const SCOPE_SAME = { native: [{ ...SAME.native[0], title: 'Ship the backoff behind a flag first', fix: 'gate the change behind RETRY_BACKOFF=1' }], outside: [{ ...SAME.outside[0], title: 'Ship the backoff behind a flag first', fix: 'gate the change behind RETRY_BACKOFF=1' }] };
|
||||
const v2 = (dir: string) => v(dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1'));
|
||||
const mixed = () => {
|
||||
const g = gated(SCOPE_SAME, PACKAGING);
|
||||
const items = gate(g.out).items.filter((i: any) => i.id.startsWith('uc'));
|
||||
expect(items.map((i: any) => `${i.id}:${i.recommended}:${i.changes}`)).toEqual(['uc1:b:scope', 'uc2:b:packaging']);
|
||||
const res = answer(g.out, 1, 'rec');
|
||||
expect(res.status, res.stderr).toBe(0);
|
||||
expect(res.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan revise --out ${esc(g.out)} --settle uc2; then edit the plan, then gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm'));
|
||||
return g;
|
||||
};
|
||||
test('incorporate first, then settle: the settle binds to the revised plan, the reopened phases rerun, and the run completes', () => {
|
||||
const g = mixed();
|
||||
const inc = r.ap(['revise', '--out', g.out, '--plan', v2(g.dir), '--incorporates', 'uc1']);
|
||||
expect(inc.status, inc.stderr).toBe(0);
|
||||
expect(inc.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo,eng$/m);
|
||||
const settled = r.ap(['revise', '--out', g.out, '--settle', 'uc2']);
|
||||
expect(settled.status, settled.stderr).toBe(0);
|
||||
expect(settled.stdout).toMatch(new RegExp(`^SETTLED: ${esc(g.run)}-uc2 \\(packaging; plan [0-9a-f]{12} unchanged; nothing reopens\\)$`, 'm'));
|
||||
expect(settled.stdout).not.toMatch(/^APPROVED:/m);
|
||||
expect(settled.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan next --out ${esc(g.out)}$`, 'm'));
|
||||
expect(readRunJson(g.out).status).toBe('running');
|
||||
phaseVia(r, g.out, 'ceo');
|
||||
expect(phaseVia(r, g.out, 'eng').outside.stdout).toContain('status=gate_pending');
|
||||
expect(answer(g.out, 2, 'rec').stdout).toContain('status=complete');
|
||||
expect(r.validate(g.out).status).toBe(0);
|
||||
});
|
||||
test('settle and incorporate in one revise; settling alone on an unreviewed plan edit is refused', () => {
|
||||
const g = mixed();
|
||||
fs.writeFileSync(g.plan, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1'));
|
||||
const early = r.ap(['revise', '--out', g.out, '--settle', 'uc2']);
|
||||
expect(early.status).toBe(3);
|
||||
expect(early.stderr).toMatch(/the plan file has edits the run has not reviewed.*\(AMENDMENT_NOT_SETTLEABLE\)$/m);
|
||||
const both = r.ap(['revise', '--out', g.out, '--plan', g.plan, '--incorporates', 'uc1', '--settle', 'uc2']);
|
||||
expect(both.status, both.stderr).toBe(0);
|
||||
expect(both.stdout).toMatch(/^AMENDMENTS: pending=none$/m);
|
||||
const run = readRunJson(g.out);
|
||||
expect(run.status).toBe('running');
|
||||
expect(run.amendments.map((a: any) => `${a.item}:${a.status}`)).toEqual(['uc1:incorporated', 'uc2:settled']);
|
||||
expect(r.ap(['next', '--out', g.out]).status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial replies resolve against persisted decisions', () => {
|
||||
test('a later `all` never overwrites an earlier override, and p1 alone with approvals unanswered keeps the gate', () => {
|
||||
const g = gated({}, {});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Pairing negative control (next-wave B2/B-a): finding pairs copied verbatim
|
||||
* from a real autoplan run. File:line proximity associates findings but never
|
||||
* confirms them; only a title match or the parent's override does, and `b`
|
||||
* needs one shared proposal or the same remedy.
|
||||
* confirms them, and neither does a title match alone: a title match with
|
||||
* the same remedy confirms, opposite remedies disagree, different remedies stay
|
||||
* unverified; the parent's override with one shared proposal also confirms.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import fixture from './fixtures/autoplan-pairing/run-20261014-pairs.json';
|
||||
@@ -36,4 +37,27 @@ describe('pairing negative control from a real run', () => {
|
||||
const [row] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'both: a recommendation edit changes what a seen reply resolves to' }]).rows;
|
||||
expect(challengeDefault(row!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'a', basis: 'plan direction; remedies differ; no shared proposal' });
|
||||
});
|
||||
|
||||
test('the same title with opposite remedies disagrees, as an ordinary finding and as a user challenge; never b', () => {
|
||||
const p = pair('constructed same title, opposite');
|
||||
const [row] = reconcile(p.native, p.outside).rows;
|
||||
expect(row).toMatchObject({ disposition: 'disagree', basis: 'title' });
|
||||
expect(row!.resolution).toStartWith('remedies conflict (same title): native "move attempts into a new retry_attempts table"');
|
||||
const [n] = challenge(p.native, 'scope'), [o] = challenge(p.outside, 'scope');
|
||||
const [crow] = reconcile([n], [o]).rows;
|
||||
expect(crow!.disposition).toBe('disagree');
|
||||
expect(challengeDefault(crow!, n, o)).toMatchObject({ recommended: 'a', basis: 'plan direction; voices disagree' });
|
||||
});
|
||||
|
||||
test('the same title with different remedies is only associated; the same remedy confirms and recommends b', () => {
|
||||
const differ = pair('constructed same title, different');
|
||||
const [row] = reconcile(differ.native, differ.outside).rows;
|
||||
expect(row).toMatchObject({ disposition: 'unverified' });
|
||||
expect(row!.resolution).toStartWith('same title, remedies differ');
|
||||
expect(challengeDefault(row!, challenge(differ.native, 'scope')[0], challenge(differ.outside, 'scope')[0])).toMatchObject({ recommended: 'a', basis: 'plan direction; remedies differ; no shared proposal' });
|
||||
const same = pair('constructed same title, same remedy');
|
||||
const [ok] = reconcile(same.native, same.outside).rows;
|
||||
expect(ok).toMatchObject({ disposition: 'confirmed', basis: 'title' });
|
||||
expect(challengeDefault(ok!, challenge(same.native, 'scope')[0], challenge(same.outside, 'scope')[0])).toMatchObject({ recommended: 'b', basis: 'both voices; same remedy' });
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@
|
||||
* required in an unattended run and lands on every decision row; an agent
|
||||
* answer also carries the owner's verbatim reply (next-wave B-c), and a
|
||||
* parseable owner reply that resolves differently from the relay is refused.
|
||||
* A gate.json written before `rec` (no rec_rev) refuses alias replies
|
||||
* until the owner sees a re-render (--rec-hash); tokens still answer.
|
||||
* B-b `recommend` changes what `rec` resolves to, so it bumps rec_rev and a
|
||||
* `rec` must carry the rec_hash of the render the owner saw.
|
||||
*/
|
||||
@@ -102,6 +104,29 @@ describe('R1/B1: `rec` (and its alias `all`) takes every recommendation, plan ch
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy gates: a gate.json written before `rec` (no rec_rev)', () => {
|
||||
test('alias replies are refused until a re-render binds --rec-hash; explicit tokens still answer', () => {
|
||||
const out = agreedRun('legacy');
|
||||
const gateFile = path.join(out, 'gate.json');
|
||||
const gate = JSON.parse(fs.readFileSync(gateFile, 'utf8'));
|
||||
expect(gate.rec_rev).toBe(0);
|
||||
delete gate.rec_rev;
|
||||
fs.writeFileSync(gateFile, JSON.stringify(gate, null, 2) + '\n');
|
||||
const before = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8');
|
||||
for (const reply of ['all', 'rec', 'approve all', 'all except uc1a']) {
|
||||
const refused = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', reply, '--by', 'human']);
|
||||
expect(refused.status, reply).toBe(3);
|
||||
expect(refused.stderr).toMatch(/written before `rec`\/`all` took plan changes.*\(GATE_REC_STALE\)$/m);
|
||||
}
|
||||
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before);
|
||||
const tokens = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a', '--by', 'human']);
|
||||
expect(tokens.status, tokens.stderr).toBe(0);
|
||||
const hash = renderGate(JSON.parse(fs.readFileSync(gateFile, 'utf8'))).match(/rec_hash=([0-9a-f]{12})/)![1]!;
|
||||
const bound = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'human']);
|
||||
expect(bound.status, bound.stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('R5: gate answers carry provenance', () => {
|
||||
test('an unattended answer without --by is refused and writes nothing; --by lands on every answered row', () => {
|
||||
const out = agreedRun('provenance');
|
||||
|
||||
@@ -88,14 +88,14 @@ describe('next/submit loop', () => {
|
||||
const ceo = runPhase(out, 'ceo');
|
||||
expect(ceo.native.status, ceo.native.stderr).toBe(0);
|
||||
expect(ceo.outside.status, ceo.outside.stderr).toBe(0);
|
||||
expect(ceo.outside.stdout).toMatch(/^PHASE_CLOSED: ceo confirmed=2 disagree=0 new=1 native_only=1 coverage=both$/m);
|
||||
expect(ceo.outside.stdout).toMatch(/^PHASE_CLOSED: ceo confirmed=0 unverified=2 disagree=0 new=1 native_only=1 coverage=both$/m);
|
||||
expect(ceo.outside.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running run=');
|
||||
const consensus = JSON.parse(fs.readFileSync(path.join(out, 'ceo-consensus.json'), 'utf8'));
|
||||
const run = readRun(out).run;
|
||||
expect(consensus.rows.map((r: any) => [r.outside_id, r.native_id, r.disposition, r.basis])).toEqual([
|
||||
[`${run}-ceo-outside-1`, `${run}-ceo-native-1`, 'confirmed', 'file_line'],
|
||||
[`${run}-ceo-outside-1`, `${run}-ceo-native-1`, 'unverified', 'file_line'],
|
||||
[`${run}-ceo-outside-2`, null, 'new', 'none'],
|
||||
[`${run}-ceo-outside-3`, `${run}-ceo-native-3`, 'confirmed', 'title'],
|
||||
[`${run}-ceo-outside-3`, `${run}-ceo-native-3`, 'unverified', 'title'],
|
||||
]);
|
||||
expect(consensus.native_only_ids).toEqual([`${run}-ceo-native-2`]);
|
||||
const eng = runPhase(out, 'eng');
|
||||
@@ -110,8 +110,9 @@ describe('next/submit loop', () => {
|
||||
const decisions = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
||||
expect(decisions.every((d: any) => d.status === 'pending' && d.gate_rev === 1)).toBe(true);
|
||||
const tasks = fs.readFileSync(path.join(out, 'tasks.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
||||
expect(tasks.length).toBe(4);
|
||||
expect(tasks[0]).toMatchObject({ schema_version: 2, status: 'blocked_on_decision', blocked_by: [`${run}-p1`], findings: [`${run}-ceo-native-1`, `${run}-ceo-outside-1`] });
|
||||
expect(tasks.length).toBe(6);
|
||||
expect(tasks[0]).toMatchObject({ schema_version: 2, status: 'blocked_on_decision', blocked_by: [`${run}-p1`], findings: [`${run}-ceo-native-1`] });
|
||||
expect(tasks.flatMap((t: any) => t.findings).filter((f: string) => f.endsWith('-ceo-outside-1'))).toHaveLength(1);
|
||||
expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toContain('GUARD_NOT_INSTALLED');
|
||||
expect(fs.readFileSync(path.join(out, 'plan.md'), 'utf8').startsWith('## URGENT, outside this plan')).toBe(true);
|
||||
// a missing prior consensus refuses the Eng dispatch
|
||||
|
||||
+88
-1
@@ -89,6 +89,93 @@
|
||||
"resolution": "Hash the rendered recommendations into the relay (for example rec_hash in the render's resolution line). Make answer reject a rec whose hash no longer matches (re-render and re-relay). Make recommend refuse after a render has been relayed unless it bumps to a fresh render. Test recommend-after-relay, then rec."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "constructed same title, opposite remedies",
|
||||
"expect": "negative",
|
||||
"source": "constructed (integration review, 2026-10-15): the same title associates two findings whose remedies are opposite",
|
||||
"outside": [
|
||||
{
|
||||
"phase": "eng",
|
||||
"severity": "High",
|
||||
"title": "Persist retry attempts in their own table",
|
||||
"file": "lib/worker.ts",
|
||||
"id": "r-eng-outside-9",
|
||||
"voice": "outside",
|
||||
"line": 40,
|
||||
"resolution": "keep attempts in the existing jobs column; do not add a table"
|
||||
}
|
||||
],
|
||||
"native": [
|
||||
{
|
||||
"phase": "eng",
|
||||
"severity": "High",
|
||||
"title": "Persist retry attempts in their own table",
|
||||
"file": "lib/worker.ts",
|
||||
"id": "r-eng-native-9",
|
||||
"voice": "native",
|
||||
"line": 90,
|
||||
"resolution": "move attempts into a new retry_attempts table"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "constructed same title, different remedies",
|
||||
"expect": "negative",
|
||||
"source": "constructed: the same title with two different, non-opposite remedies is associated, not confirmed",
|
||||
"outside": [
|
||||
{
|
||||
"phase": "eng",
|
||||
"severity": "High",
|
||||
"title": "Persist retry attempts in their own table",
|
||||
"file": "lib/worker.ts",
|
||||
"id": "r-eng-outside-10",
|
||||
"voice": "outside",
|
||||
"line": 40,
|
||||
"resolution": "index the attempts by tenant"
|
||||
}
|
||||
],
|
||||
"native": [
|
||||
{
|
||||
"phase": "eng",
|
||||
"severity": "High",
|
||||
"title": "Persist retry attempts in their own table",
|
||||
"file": "lib/worker.ts",
|
||||
"id": "r-eng-native-10",
|
||||
"voice": "native",
|
||||
"line": 90,
|
||||
"resolution": "partition the attempts table by month"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "constructed same title, same remedy",
|
||||
"expect": "positive",
|
||||
"source": "constructed: the same title and the same remedy (case and punctuation aside) confirm",
|
||||
"outside": [
|
||||
{
|
||||
"phase": "eng",
|
||||
"severity": "High",
|
||||
"title": "Persist retry attempts in their own table",
|
||||
"file": "lib/worker.ts",
|
||||
"id": "r-eng-outside-11",
|
||||
"voice": "outside",
|
||||
"line": 40,
|
||||
"resolution": "Move attempts into a new retry_attempts table."
|
||||
}
|
||||
],
|
||||
"native": [
|
||||
{
|
||||
"phase": "eng",
|
||||
"severity": "High",
|
||||
"title": "Persist retry attempts in their own table",
|
||||
"file": "lib/worker.ts",
|
||||
"id": "r-eng-native-11",
|
||||
"voice": "native",
|
||||
"line": 90,
|
||||
"resolution": "move attempts into a new retry_attempts table"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user