diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 34e34d036..13775539c 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -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 --plan --native-model --outside-model [--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 --gate-rev --reply "" --by > [--json] --by agent: needs --owner-reply "" (- reads stdin; --owner-reply-file ), stored verbatim; @@ -323,6 +323,8 @@ async function doRun(out: string, rest: string[], json: boolean): Promise 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 { 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; } diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index afc0f3162..1718c208a 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -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; } diff --git a/test/autoplan-run-api.test.ts b/test/autoplan-run-api.test.ts index dea5b9f89..7d7b9b350 100644 --- a/test/autoplan-run-api.test.ts +++ b/test/autoplan-run-api.test.ts @@ -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; @@ -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 = { 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); });