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);
});