fix(autoplan): a spend cap fails closed: only a call refused before sending keeps its attempt; a sent call with unknown usage settles at its reservation

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 12:07:44 +00:00
1 parent 167638d41f
commit 3b4968bcb7
3 files changed
+31 -17

No files matched your search

+6 -4
View File
@@ -83,7 +83,7 @@ DRIVER: ${driverFile()}
--settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4)
run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] ...
next in a loop, only when a runner executes both voices here (hosts without subagents: --native-runner api --runner api);
--deadline cancels the children it started; --max-tokens caps each api call's output (Anthropic default 16384), and a
--deadline cancels the children it started; --max-tokens caps each api call's output (default 16384; OpenAI counts reasoning tokens in it), and a
response cut off at that limit is reported truncated; submit's result checks judge every completed response
answer --out <dir> --gate-rev <rev> --reply "<rec | rec except d3a | d3b uc1a | as-is>" --by <human|agent:<id>> [--json]
--by agent:<id> needs --owner-reply "<owner's words>" (- reads stdin; --owner-reply-file <path>), stored verbatim;
@@ -323,6 +323,8 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
};
const usdOf = (voice: Voice, used: { input_tokens?: number; output_tokens?: number } | undefined) =>
isApi(voice) && pricingRow(modelOf(voice)) && typeof used?.input_tokens === 'number' && typeof used?.output_tokens === 'number' ? estimateCostUsd({ input: used.input_tokens, output: used.output_tokens }, modelOf(voice)) : undefined;
// Refused before any request left this machine, so nothing was billed: the attempt stays open for the rerun. Any other failure settles at its usage, or its reservation when the usage is unknown.
const NOT_SENT = new Set(['runner_unavailable', 'cli_missing', 'model_unselected']);
for (let guard = 0; guard < 40; guard++) {
const code = withRunLock(out, () => doNext(out, rest, true, price));
if (code !== EXIT.ok) return code;
@@ -346,12 +348,12 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
const res = await runner.run({ promptFile: open.prompt, outFile: `${open.result}.raw`, model: voice === 'native' ? nativeModel : outsideModel, timeoutMs: Math.min(remaining, runnerId === 'api' ? apiTimeoutMs(maxTokens) : 600_000), cwd: process.cwd(), gate: 'execution', maxTokens: runnerId === 'api' ? maxTokens ?? DEFAULT_MAX_TOKENS : maxTokens });
if (timer) clearTimeout(timer);
if (!json) process.stdout.write(`VOICE: ${phase} ${voice} ${res.status} runner=${res.runner} model=${res.model ?? 'unknown'}\n`);
if (res.status === 'cancelled') { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
const retire = () => { if (!NOT_SENT.has(res.reason ?? '')) retireAttempt(out, phase as Phase, voice, open.attempt, usdOf(voice, res.usage) ?? 'unknown'); };
if (res.status === 'cancelled') { withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
if (res.status !== 'completed' && res.status !== 'unverified') {
if (voice === 'outside') { withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} ${res.status}${res.detail ? `: ${res.detail}` : ''}`)); continue; }
process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.reason ? `: ${res.reason}` : ''}${res.detail ? `: ${res.detail}` : ''}\n`);
const spent = usdOf(voice, res.usage);
withRunLock(out, () => { if (spent !== undefined) retireAttempt(out, phase as Phase, voice, open.attempt, spent); const s = readState(out); s.status = 'interrupted'; writeState(s); });
withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); });
process.stdout.write(resultLineFor('interrupted', out) + '\n');
return EXIT.fail;
}
+3 -2
View File
@@ -229,6 +229,8 @@ export function nextStep(o: NextOptions): NextResult {
if (estimate.line && (['native', 'outside'] as const).some(voice => usdFor(voice) === estimate.usd)) lines.push(estimate.line);
if (first) lines.push(reviewEstimateLine(state, estimate.usd));
const fresh = (['native', 'outside'] as const).filter(voice => { const v = voiceState(state, phase!, voice); const open = openAttempt(v); return !voiceTerminal(v) && (!open || (open.state === 'execution_unknown' && o.redispatch?.includes(open.attempt))); });
const resultOf = (voice: Voice, attempt: string) => path.join(state.out, attemptFile(phase!, voice, attempt.slice(state.run.length + 1), 'md'));
fresh.forEach((voice, i) => bindPromptResult(prompts[voice], resultOf(voice, `${state.run}-a${state.attempt_counter + i + 1}`)));
const before = summarize(readLedger(path.join(state.out, FILES.spend), state.spend_cap_usd));
const asked = fresh.reduce((sum, voice) => sum + usdFor(voice), 0);
if (state.spend_cap_usd !== null && fresh.length && before.worst_case_usd + asked > state.spend_cap_usd + 1e-9) {
@@ -250,8 +252,7 @@ export function nextStep(o: NextOptions): NextResult {
continue;
}
const attempt = `${state.run}-a${++state.attempt_counter}`;
const resultFile = path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md'));
bindPromptResult(prompts[voice], resultFile);
const resultFile = resultOf(voice, attempt);
const usd = usdFor(voice);
try { reserve(path.join(state.out, FILES.spend), attempt, usd, { cap: state.spend_cap_usd, label: `${phase}/${voice}` }); }
catch (e: any) { if (e.code === 'SPEND_CAP_EXCEEDED') { state.attempt_counter--; writeState(state); throw new RunError('SPEND_CAP_EXCEEDED', e.message, 3); } throw e; }
+22 -11
View File
@@ -13,7 +13,7 @@ import { DEFAULT_MAX_TOKENS } from '../lib/outside-voice-api';
import { AUTOPLAN, ROOT, SMALL_PLAN, env } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-run-api-'));
let mode: 'ok' | 'cut' | 'bad-outside' | 'down' = 'ok';
let mode: 'ok' | 'cut' | 'bad-outside' | 'hang' = 'ok';
let calls = 0;
let openaiLimits: unknown[] = [];
let server: ReturnType<typeof Bun.serve>;
@@ -22,7 +22,7 @@ beforeAll(() => {
port: 0,
async fetch(req) {
calls++;
if (mode === 'down') return new Response('overloaded', { status: 503 });
if (mode === 'hang') { await Bun.sleep(5000); return new Response('late', { status: 504 }); }
const body = await req.json();
const prompt = String(body.messages[0].content);
const m = /INPUT: ([a-z]+) ([0-9a-f]{64})/.exec(prompt.slice(prompt.lastIndexOf('RESULT FORMAT')))!;
@@ -37,7 +37,7 @@ beforeAll(() => {
});
afterAll(() => { server.stop(true); fs.rmSync(TMP, { recursive: true, force: true }); });
async function run(name: string, extra: string[] = []) {
async function run(name: string, extra: string[] = [], keys: Record<string, string> = { ANTHROPIC_API_KEY: 'ak-test', OPENAI_API_KEY: 'sk-test' }) {
const dir = path.join(TMP, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'plan.md'), SMALL_PLAN);
@@ -45,7 +45,7 @@ async function run(name: string, extra: string[] = []) {
const base = `http://127.0.0.1:${server.port}`;
const proc = Bun.spawn(['bun', AUTOPLAN, 'run', '--out', out, '--plan', path.join(dir, 'plan.md'), '--no-ui', '--no-dx', '--native-model', 'anthropic/claude-opus-5-5', '--outside-model', 'openai/gpt-6-astra', '--runner', 'api', '--native-runner', 'api', ...extra], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
env: env({ ANTHROPIC_API_KEY: 'ak-test', OPENAI_API_KEY: 'sk-test', ANTHROPIC_BASE_URL: base, OPENAI_BASE_URL: `${base}/v1`, GSTACK_AUTOPLAN_CHECKPOINT_DIR: '', GSTACK_STATE_ROOT: path.join(TMP, 'state') }),
env: env({ ANTHROPIC_API_KEY: '', OPENAI_API_KEY: '', ...keys, ANTHROPIC_BASE_URL: base, OPENAI_BASE_URL: `${base}/v1`, GSTACK_AUTOPLAN_CHECKPOINT_DIR: '', GSTACK_STATE_ROOT: path.join(TMP, 'state') }),
});
const timer = setTimeout(() => proc.kill('SIGKILL'), 120_000);
const [status, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
@@ -122,16 +122,16 @@ describe('gstack-autoplan run --native-runner api --runner api', () => {
expect(calls).toBe(0);
}, 150_000);
test('a failed call that reports no usage charges nothing: its attempt stays open and the rerun reuses it at the cap that admitted it', async () => {
mode = 'down';
const first = await run('down-retry', ['--spend-cap', '8']);
test('a call refused before any request leaves charges nothing: with no key the attempt stays open and the rerun reuses it at the cap that admitted it', async () => {
mode = 'ok';
calls = 0;
const first = await run('no-key', ['--spend-cap', '8'], { OPENAI_API_KEY: 'sk-test' });
expect(first.state.status).toBe('interrupted');
expect(calls).toBe(0);
const open = first.state.phases.ceo.native.attempts[0];
expect(open.state).toBe('dispatched');
const before = JSON.parse(fs.readFileSync(path.join(first.out, 'spend.json'), 'utf8'));
expect(before.settled).toEqual([]);
mode = 'ok';
const retry = await run('down-retry', ['--spend-cap', '8']);
expect(JSON.parse(fs.readFileSync(path.join(first.out, 'spend.json'), 'utf8')).settled).toEqual([]);
const retry = await run('no-key', ['--spend-cap', '8']);
expect(retry.status, retry.stderr).toBe(0);
expect(retry.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${retry.out}`);
expect(retry.state.phases.ceo.native.attempts.map((a: any) => a.attempt)).toEqual([open.attempt]);
@@ -140,4 +140,15 @@ describe('gstack-autoplan run --native-runner api --runner api', () => {
expect(ledger.released ?? []).toEqual([]);
for (const s of ledger.settled) expect(s.label).toMatch(/^(ceo|eng)\/(native|outside)$/);
}, 150_000);
test('a call that was sent and timed out with no usage is charged its reservation, so the cap binds on the rerun', async () => {
mode = 'hang';
const first = await run('timed-out', ['--spend-cap', '8', '--deadline', '0.02']);
expect(first.state.status).toBe('interrupted');
const sent = first.state.phases.ceo.native.attempts[0];
expect(sent.state).toBe('superseded');
const ledger = JSON.parse(fs.readFileSync(path.join(first.out, 'spend.json'), 'utf8'));
expect(ledger.reserved.map((r: any) => r.attempt)).not.toContain(sent.attempt);
expect(ledger.settled.find((s: any) => s.attempt === sent.attempt).usd).toBe('unknown');
}, 150_000);
});