Files
gstack/lib/autoplan-state.ts
T
Garry Tanandcapy-ai[bot] c71d764ba4 v1.91.115.0 feat: field list 2 wave A: autoplan round cap and public export, eval-plan diagnostics and model rules, --for review-voice (#3234)
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(lease): name the double-crash limit of breaking a dead lock

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 89bf556c11)

* test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7f6c70459a)

* test: the DRIVER template names the reviewer trailer relay (pregate lane)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2545d9a6d2)

* test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit b509096218)

* feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 9335e45de2)

* test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a7a974d268)

* test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog entry for field list 2 wave A

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.115.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-17 02:40:45 +00:00

135 lines
9.0 KiB
TypeScript

/**
* autoplan-state — the unattended runner's on-disk state (plan B2): the
* RunState shape (the B0 run manifest plus the runner's own fields), the
* typed RunError every command maps to a result code and exit code, the fixed
* file names, the append-only attempts journal, atomic run.json writes, and
* the per-voice reads the gate and export share. No command logic lives here.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Phase, Scope } from './autoplan-prompts';
import type { DecisionRow, FindingRow, RunManifest, VoiceOutcome } from './headless-artifacts';
import { readJsonl } from './headless-artifacts';
import type { ResultCodeName } from './result-codes';
export type Voice = 'native' | 'outside';
export interface AttemptState { attempt: string; state: 'dispatched' | 'submitted' | 'execution_unknown' | 'cancelled' | 'superseded' | 'deferred'; prompt: string; result: string; estimate_usd: number; dispatched_at: string; terminal_at?: string }
export interface VoiceState extends VoiceOutcome {
attempt?: string; attempts?: AttemptState[]; result_sha256?: string; findings?: number; label?: string; usage?: Record<string, unknown>; detail?: string;
/** verified: the INPUT receipt matched the snapshot; attested: a --record bound without one, on the parent's word (attest_reason). */
receipt?: 'verified' | 'attested'; attest_reason?: string; findings_source?: 'fence' | 'table' | 'list' | 'none'; reviewed_sha256?: string;
}
/** An accepted plan change (gate option with changes_plan) the plan must absorb before the run continues. */
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;
}
/** A revise that changed the plan under a phase at its round cap: the phase keeps its review, the delta becomes a gate item. */
export interface CappedDelta {
k: number; phase: string; round: number; cap: number; old_plan_sha256: string; new_plan_sha256: string; added: number; removed: number;
status: 'pending' | 'accepted' | 'reopened'; at: string; decided_at?: string;
}
/** Why a run stopped `incomplete` with work left: the structured budget_exhausted stop `resume` lifts after a budget raise. */
export interface RunStop {
reason: 'budget_exhausted'; phase?: string; cap_usd: number | null; max_rounds: number | null; rounds: Record<string, number>;
spent: number; reserved: number; unknown: number; unknown_count: number; next_estimate_usd: number; at: string;
}
/** A phase closed accepted-with-findings (`closeDeferred`): its open findings carry into tasks.jsonl and the gate. */
export interface DeferredClose {
reason: string; by: string; cause: 'cap' | 'owner'; at: string; round: number; counts: Record<string, number>;
carried_file?: string; blockers: string[]; open: number;
}
export interface PhaseState {
snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string;
native?: VoiceState; outside?: VoiceState; consensus?: Record<string, unknown>; skipped?: string;
prompts?: { native: string; outside: string; nativeResult: string; outsideResult: string };
sections?: { loaded: string[]; skipped: string[]; checklist: boolean };
deferred?: DeferredClose;
/** A confirm round (r > 1): the revision this phase last reviewed. */
confirm?: { round: number; previous_sha256?: string; added: number; removed: number };
}
export interface RunState extends RunManifest {
revision: number; out: string; plan_source: string; active_plan: string; restore: string; scope: Scope; phase_order: Phase[];
phases: Record<string, PhaseState>; native_model?: string; outside_model?: string; outside_enabled: boolean; attempt_counter: number;
spend_cap_usd: number | null; deadline_at?: string; light?: boolean; history: Array<Record<string, unknown>>; created_at: string; updated_at: string;
/** The gstack that last wrote this run (lib/autoplan-migrate.ts); absent on runs written before 1.91.96. */
gstack_version?: string;
/** The --plan file the owner edits (drift hints, revise), the --plan-heading it was cut from, and the scope flags revise re-detects with. */
plan_input?: string; plan_heading?: string; scope_flags?: Record<string, boolean | undefined>; scaffolded?: boolean;
plan_revision?: number; amendments?: Amendment[];
plan_reality?: { plan_sha256: string; checker: string; verdict: string; line: string };
/** Review rounds per phase (null: uncapped); round_grants are the owner's per-phase raises at the gate. */
max_rounds?: number | null; round_grants?: Record<string, number>;
stop?: RunStop; stops?: RunStop[]; capped_deltas?: CappedDelta[];
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
/** The one terminal-voice predicate `next`, `submit`, `resume` and phase close share: a terminal voice is never dispatched or resurrected. */
export function voiceTerminal(v: { status?: string } | undefined): boolean {
return (TERMINAL_VOICE_STATUSES as readonly string[]).includes(String(v?.status));
}
export const pendingAmendments = (state: RunState): Amendment[] => (state.amendments ?? []).filter(a => a.status === 'pending');
export class RunError extends Error {
/** `status`: the run status the refusal leaves (default refused); `lines`: what the command still reports on stdout. */
constructor(public code: ResultCodeName, public detail: string, public exit: 1 | 3 = 1, public after: { status?: string; lines?: string[] } = {}) { super(`${code}: ${detail}`); }
}
export const FILES = { run: 'run.json', journal: 'attempts.jsonl', lock: 'run.lock', spend: 'spend.json', active: 'plan-active.md', restore: 'restore.md', source: 'plan-source.md' } as const;
export const now = () => new Date().toISOString();
export function journal(out: string, row: Record<string, unknown>): void {
fs.appendFileSync(path.join(out, FILES.journal), JSON.stringify({ ts: now(), ...row }) + '\n');
}
export function readJournal(out: string): Array<Record<string, any>> {
const file = path.join(out, FILES.journal);
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap(l => { try { return [JSON.parse(l)]; } catch { return []; } });
}
export function writeState(state: RunState): void {
state.revision += 1;
state.updated_at = now();
const file = path.join(state.out, FILES.run);
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
fs.renameSync(tmp, file);
}
export function readState(out: string): RunState {
const file = path.join(out, FILES.run);
if (!fs.existsSync(file)) throw new RunError('RUN_NOT_INITIALIZED', out, 3);
const state = JSON.parse(fs.readFileSync(file, 'utf8')) as RunState;
// The directory may have been moved or copied: paths inside it are re-rooted on every read.
const previous = state.out;
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.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])!;
for (const v of [ps.native, ps.outside]) for (const a of v?.attempts ?? []) { a.prompt = reroot(a.prompt)!; a.result = reroot(a.result)!; }
}
}
return state;
}
/** The canonical rows one completed voice returned (`<phase>-<voice>.md.findings.jsonl`). */
export function voiceRows(state: RunState, phase: string, voice: Voice): FindingRow[] {
const v = state.phases[phase]?.[voice];
if (v?.status !== 'completed' || !v.output) return [];
const file = path.join(state.out, `${v.output}.findings.jsonl`);
return fs.existsSync(file) ? (readJsonl(file).rows as unknown as FindingRow[]) : [];
}
export function readDecisions(out: string): DecisionRow[] {
const file = path.join(out, 'decisions.jsonl');
return fs.existsSync(file) ? (readJsonl(file).rows as unknown as DecisionRow[]) : [];
}
export function writeDecisions(out: string, rows: DecisionRow[]): void {
const file = path.join(out, 'decisions.jsonl');
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''));
fs.renameSync(tmp, file);
}