mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
fix(autoplan): a spend cap binds on every unattended host; --estimate-usd persists; DRIVER names --usage-usd (GBRA-223 claim 4)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
a2aea43fdf
commit
78a43a3c96
8 files changed
+58
-16
No files matched your search
+4
-4
@@ -57,16 +57,16 @@ line yourself; gstack never writes the plan.
|
||||
|
||||
### Commands
|
||||
|
||||
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--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 <n> says otherwise, so --spend-cap binds.
|
||||
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--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 <n> sets that reservation and the run keeps it for later phases.
|
||||
- `gstack-autoplan next --out <dir> [--redispatch <attempt>]`
|
||||
later calls: open the next phase or reprint open attempts; never redispatches on its own.
|
||||
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]`
|
||||
bind a free-form review record (canonical fence, one findings table or list, or `No findings.`).
|
||||
- `gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>`
|
||||
bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses).
|
||||
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id> [--reconcile <jsonl>]`
|
||||
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 <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id> [--usage-usd <n>] [--reconcile <jsonl>]`
|
||||
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 <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"`
|
||||
close the phase native-only when no outside voice can run.
|
||||
- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]`
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ DRIVER: ${driverFile()}
|
||||
[--no-outside] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--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 <out>/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
|
||||
|
||||
+4
-1
@@ -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 <min>` 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
|
||||
<n>` sets the reservation, which the run keeps for later phases; `submit
|
||||
--usage-usd <n>` settles an attempt at its real cost. `--deadline <min>` makes the
|
||||
`run` subcommand cancel the children it started and marks the run
|
||||
`interrupted`.
|
||||
|
||||
|
||||
@@ -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 <n> 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 <n> 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. */
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface DispatchCounts { total: number; by_phase: Record<string, Partia
|
||||
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;
|
||||
spend_cap_usd: number | null; /** `next --estimate-usd`, kept for every later attempt. */ estimate_usd?: number; 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. */
|
||||
|
||||
@@ -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 <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--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 <n> says otherwise, so --spend-cap binds` },
|
||||
{ cmd: 'gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--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 <n> sets that reservation and the run keeps it for later phases` },
|
||||
{ cmd: 'gstack-autoplan next --out <dir> [--redispatch <attempt>]', does: 'later calls: open the next phase or reprint open attempts; never redispatches on its own' },
|
||||
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]', does: 'bind a free-form review record (canonical fence, one findings table or list, or `No findings.`)' },
|
||||
{ cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' },
|
||||
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id> [--reconcile <jsonl>]', 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 <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id> [--usage-usd <n>] [--reconcile <jsonl>]', 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 <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"', does: 'close the phase native-only when no outside voice can run' },
|
||||
{ cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim' },
|
||||
{ cmd: 'gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"', does: 'change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`)' },
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user