diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index b8ed9b8c2..fad3a04f7 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -57,16 +57,16 @@ line yourself; gstack never writes the plan. ### Commands -- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--scaffold]` - first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd says otherwise, so --spend-cap binds. +- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--scaffold]` + first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended); an unattended run with --spend-cap (or any unattended Capy run) reserves 5.00 per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases. - `gstack-autoplan next --out [--redispatch ]` later calls: open the next phase or reprint open attempts; never redispatches on its own. - `gstack-autoplan submit --out --phase

--voice native|outside --record --model [--attempt ]` bind a free-form review record (canonical fence, one findings table or list, or `No findings.`). - `gstack-autoplan submit ... --record --attest "" --attempt ` bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses). -- `gstack-autoplan submit --out

--phase

--voice native|outside --result --model --attempt [--reconcile ]` - bind a canonical result (the RESULT FORMAT below); on the submit that closes the phase, `--reconcile` takes your own pairing of findings both voices raised, one `{"outside_id","native_id","disposition","resolution"}` row each, over the structural one. +- `gstack-autoplan submit --out

--phase

--voice native|outside --result --model --attempt [--usage-usd ] [--reconcile ]` + bind a canonical result (the RESULT FORMAT below); `--usage-usd` settles the attempt at what the reviewer actually cost (else it stays priced unknown at its reservation); on the submit that closes the phase, `--reconcile` takes your own pairing of findings both voices raised, one `{"outside_id","native_id","disposition","resolution"}` row each, over the structural one. - `gstack-autoplan submit --out

--phase

--voice outside --unavailable|--failed ""` close the phase native-only when no outside voice can run. - `gstack-autoplan answer --out

--gate-rev --reply "" --by human|agent: [--owner-reply |-] [--rec-hash ]` diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index c42062867..753e182c0 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -59,7 +59,7 @@ DRIVER: ${driverFile()} [--no-outside] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json] --max-rounds (first next): review rounds per phase (default 2 unattended); at the cap a phase closes accepted-with-findings --docs-only (first next; also detected when every Implementation-plan path is docs-class): CEO (native voice only) + Eng - --estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds + --estimate-usd: reserved per new attempt, kept for the rest of the run; with --spend-cap (or on Capy) unattended runs reserve a default so the cap binds --scaffold (first next): a plan missing one of its two sections is reviewed from /plan-source.md with the heading added --light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything --no-carry-forward (first next): every later gate revision goes to the owner, even one that asks nothing new diff --git a/docs/unattended.md b/docs/unattended.md index 43fbe0f74..a6197d807 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -441,7 +441,10 @@ Concurrency and recovery: every state write is a locked read-modify-write (`test/autoplan-run-recovery.test.ts`, `test/autoplan-run-concurrency.test.ts`, `test/autoplan-answer-invalidation.test.ts`). `--spend-cap` admits each attempt through `spend.json` (`SPEND_CAP_EXCEEDED` refuses, exit 3); host-subagent -spend is `unknown` and counted in `worst_case`. `--deadline ` makes the +spend is `unknown` and counted in `worst_case`. Under a cap (or on Capy) each +unattended attempt reserves the default per voice unless `next --estimate-usd +` sets the reservation, which the run keeps for later phases; `submit +--usage-usd ` settles an attempt at its real cost. `--deadline ` makes the `run` subcommand cancel the children it started and marks the run `interrupted`. diff --git a/lib/autoplan-budget.ts b/lib/autoplan-budget.ts index acb10f2ec..75a8fd362 100644 --- a/lib/autoplan-budget.ts +++ b/lib/autoplan-budget.ts @@ -21,7 +21,7 @@ import { FILES, RunError, journal, now, readJournal, voiceRows, type RunState, t import { readLedger, release, setCap, summarize } from './spend-ledger'; export const DEFAULT_MAX_ROUNDS = 2; -/** Reserved per voice for a host-subagent attempt with no `--estimate-usd` in an unattended Capy run. */ +/** Reserved per voice for a host-subagent attempt with no `--estimate-usd` in an unattended run with a spend cap or on Capy. */ export const DEFAULT_HOST_ESTIMATE_USD = 5; /** Round r of a phase: 1 plus the times it was reopened. */ @@ -59,11 +59,15 @@ export function roundsLine(state: RunState): string { return `ROUNDS: ${state.phase_order.map(cell).join(' ')} max_rounds=${state.max_rounds ?? 'none'} host_spend=unknown`; } -/** The estimate one new attempt reserves: the flag, else the Capy unattended default, else 0 (host spend unknown, not admitted). */ +/** + * The estimate one new attempt reserves: the flag, else the run's persisted `--estimate-usd`, else the unattended + * default when the run has a spend cap or runs on Capy, else 0 (host spend unknown, not admitted). + */ export function attemptEstimate(state: RunState, flag: number | undefined, env: NodeJS.ProcessEnv): { usd: number; line?: string } { - if (flag !== undefined) return { usd: flag }; - if (state.session_kind !== 'unattended' || env.GSTACK_CAPY !== '1') return { usd: 0 }; - return { usd: DEFAULT_HOST_ESTIMATE_USD, line: `ESTIMATE: ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice reserved for each host-subagent attempt (Capy unattended default; --estimate-usd sets it); actual host spend stays unknown` }; + const set = flag ?? state.estimate_usd; + if (set !== undefined) return { usd: set }; + if (state.session_kind !== 'unattended' || (env.GSTACK_CAPY !== '1' && state.spend_cap_usd === null)) return { usd: 0 }; + return { usd: DEFAULT_HOST_ESTIMATE_USD, line: `ESTIMATE: ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice reserved for each host-subagent attempt (unattended default under a spend cap or on Capy; --estimate-usd sets it for the rest of the run); actual host spend stays unknown` }; } /** Stop the run: terminal `incomplete` with the structured budget_exhausted stop, journaled. Returns the STOP and RECOVER lines. */ diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index d6a4fbdbd..68396e6ee 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -219,6 +219,7 @@ export function nextStep(o: NextOptions): NextResult { if (ps.outside?.reason === 'docs_only') lines.push(`OUTSIDE: ${phase} not_run reason=docs_only (native voice only)`); if (ps.sections) lines.push(`Skipped sections: ${ps.sections.skipped.length ? ps.sections.skipped.join(',') : 'none'} (scope: ui=${state.scope.ui ? 'yes' : 'no'},dx=${state.scope.dx ? 'yes' : 'no'},source=${scopeSourceLabel(state.scope)}; checklist=${ps.sections.checklist ? 'loaded' : 'none'})`); const prompts = ps.prompts!; + if (o.estimateUsd !== undefined) state.estimate_usd = o.estimateUsd; const estimate = attemptEstimate(state, o.estimateUsd, process.env); if (estimate.line) lines.push(estimate.line); if (first) lines.push(reviewEstimateLine(state, estimate.usd)); diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts index 01b345688..d41b1953a 100644 --- a/lib/autoplan-state.ts +++ b/lib/autoplan-state.ts @@ -58,7 +58,7 @@ export interface DispatchCounts { total: number; by_phase: Record; native_model?: string; outside_model?: string; outside_enabled: boolean; attempt_counter: number; - spend_cap_usd: number | null; deadline_at?: string; light?: boolean; history: Array>; created_at: string; updated_at: string; + spend_cap_usd: number | null; /** `next --estimate-usd`, kept for every later attempt. */ estimate_usd?: number; deadline_at?: string; light?: boolean; history: Array>; 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. */ diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts index d4d3cabf5..b7f5c5352 100644 --- a/scripts/resolvers/autoplan-driver.ts +++ b/scripts/resolvers/autoplan-driver.ts @@ -17,11 +17,11 @@ import type { ResolverFn } from './types'; /** One entry per command line the loop runs, as the field-wave plan specifies them. */ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ - { cmd: 'gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--scaffold]', does: `first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default ${DEFAULT_MAX_ROUNDS} unattended), and an unattended Capy run reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per host attempt unless --estimate-usd says otherwise, so --spend-cap binds` }, + { cmd: 'gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--scaffold]', does: `first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default ${DEFAULT_MAX_ROUNDS} unattended); an unattended run with --spend-cap (or any unattended Capy run) reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases` }, { cmd: 'gstack-autoplan next --out [--redispatch ]', does: 'later calls: open the next phase or reprint open attempts; never redispatches on its own' }, { cmd: 'gstack-autoplan submit --out --phase

--voice native|outside --record --model [--attempt ]', does: 'bind a free-form review record (canonical fence, one findings table or list, or `No findings.`)' }, { cmd: 'gstack-autoplan submit ... --record --attest "" --attempt ', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' }, - { cmd: 'gstack-autoplan submit --out

--phase

--voice native|outside --result --model --attempt [--reconcile ]', does: 'bind a canonical result (the RESULT FORMAT below); on the submit that closes the phase, `--reconcile` takes your own pairing of findings both voices raised, one `{"outside_id","native_id","disposition","resolution"}` row each, over the structural one' }, + { cmd: 'gstack-autoplan submit --out

--phase

--voice native|outside --result --model --attempt [--usage-usd ] [--reconcile ]', does: 'bind a canonical result (the RESULT FORMAT below); `--usage-usd` settles the attempt at what the reviewer actually cost (else it stays priced unknown at its reservation); on the submit that closes the phase, `--reconcile` takes your own pairing of findings both voices raised, one `{"outside_id","native_id","disposition","resolution"}` row each, over the structural one' }, { cmd: 'gstack-autoplan submit --out

--phase

--voice outside --unavailable|--failed ""', does: 'close the phase native-only when no outside voice can run' }, { cmd: 'gstack-autoplan answer --out

--gate-rev --reply "" --by human|agent: [--owner-reply |-] [--rec-hash ]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim' }, { cmd: 'gstack-autoplan recommend --out --gate-rev --item --option --basis ""', does: 'change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`)' }, diff --git a/test/autoplan-budget.test.ts b/test/autoplan-budget.test.ts index 82976daf1..56e6a8ad4 100644 --- a/test/autoplan-budget.test.ts +++ b/test/autoplan-budget.test.ts @@ -1,7 +1,7 @@ /** * Field list 2, W5: the autoplan run budget on the simulated Capy host. An - * unattended Capy run reserves the default per-voice estimate so --spend-cap - * binds, and a refused dispatch stops the run `incomplete` with a structured + * unattended run under --spend-cap (or on Capy) reserves the default per-voice + * estimate so the cap binds, `--estimate-usd` persists for later phases, and a refused dispatch stops the run `incomplete` with a structured * stop object that export, validate and resume understand; `budget` journals * the owner's raise. `close --deferred` keeps a dispatched voice's * reservation, routes an open Critical to the gate and carries open findings @@ -258,3 +258,37 @@ describe('confirm rounds', () => { expect(rows[1].settled_by).toBeUndefined(); }); }); + +describe('the cap binds on every unattended host (GBRA-223 claim 4)', () => { + const OFF_CAPY = { GSTACK_CAPY: '' }; + test('a capped unattended run off Capy reserves the default per voice, so the cap stops it; with no cap it reserves nothing', () => { + const dir = path.join(r.host.base, 'offcapy'); + const out = path.join(dir, 'run'); + const first = r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui', '--spend-cap', '12'], OFF_CAPY); + expect(first.status, first.stderr).toBe(0); + expect(first.stdout).toMatch(/^ESTIMATE: 5\.00 per voice reserved for each host-subagent attempt/m); + expect(spend(out).reserved.map((x: any) => x.estimate_usd)).toEqual([DEFAULT_HOST_ESTIMATE_USD, DEFAULT_HOST_ESTIMATE_USD]); + phaseVia(r, out, 'ceo', NONE); + const stopped = r.ap(['next', '--out', out], OFF_CAPY); + expect(stopped.status).toBe(3); + expect(stopped.stderr).toMatch(/\(SPEND_CAP_EXCEEDED\)$/m); + const freeDir = path.join(r.host.base, 'offcapy-nocap'); + const free = path.join(freeDir, 'run'); + const uncapped = r.ap(['next', '--out', free, '--plan', writePlan(freeDir), '--no-ui'], OFF_CAPY); + expect(uncapped.status, uncapped.stderr).toBe(0); + expect(uncapped.stdout).not.toMatch(/^ESTIMATE:/m); + expect(spend(free).reserved.map((x: any) => x.estimate_usd)).toEqual([0, 0]); + }); + + test('--estimate-usd persists: later phases reserve it without the flag', () => { + const s = start(['--spend-cap', '40', '--estimate-usd', '3']); + expect(spend(s.out).reserved.map((x: any) => x.estimate_usd)).toEqual([3, 3]); + expect(readRunJson(s.out).estimate_usd).toBe(3); + phaseVia(r, s.out, 'ceo', NONE); + expect(spend(s.out).settled.map((x: any) => x.reserved_usd)).toEqual([3, 3]); + const eng = r.ap(['next', '--out', s.out]); + expect(eng.status, eng.stderr).toBe(0); + expect(eng.stdout).toMatch(/^PHASE: eng /m); + expect(spend(s.out).reserved.map((x: any) => x.estimate_usd)).toEqual([3, 3]); + }); +});