fix(autoplan): a truncated api call settles its cost and a retry is admitted afresh against the cap

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 11:30:36 +00:00
1 parent f5e603c75e
commit da4fd1975b
3 files changed
+52 -5

No files matched your search

+8 -4
View File
@@ -44,7 +44,7 @@ import { exportPublic } from '../lib/autoplan-public';
import { migrateRun } from '../lib/autoplan-migrate';
import { reviseRun } from '../lib/autoplan-revise';
import { pendingAmendments, type RunState, type Voice } from '../lib/autoplan-state';
import { FILES, RunError, currentPhase, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
import { FILES, RunError, currentPhase, fitReservation, initRun, markOutside, nextStep, readState, resumeRun, retireAttempt, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
import { driverFile, type Phase } from '../lib/autoplan-prompts';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voice-runner';
@@ -321,6 +321,8 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
const row = isApi(voice) ? pricingRow(modelOf(voice)) : undefined;
return row ? +((fs.statSync(promptFile).size / 3 * row.input_per_mtok + (maxTokens ?? DEFAULT_MAX_TOKENS) * row.output_per_mtok) / 1e6).toFixed(6) : undefined;
};
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;
for (let guard = 0; guard < 40; guard++) {
const code = withRunLock(out, () => doNext(out, rest, true, price));
if (code !== EXIT.ok) return code;
@@ -335,6 +337,8 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
const open = v.attempts?.find(a => a.state === 'dispatched');
if (!open) continue;
const runnerId = voice === 'native' ? nativeRunner : outsideRunner;
const cost = price(voice, open.prompt);
if (cost !== undefined) withRunLock(out, () => fitReservation(out, phase as Phase, voice, open.attempt, cost));
const runner = createRunner(runnerId);
const remaining = deadlineAt ? deadlineAt - Date.now() : runnerId === 'api' ? apiTimeoutMs(maxTokens) : 300_000;
if (remaining <= 0) { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
@@ -346,12 +350,12 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
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`);
withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); });
withRunLock(out, () => { retireAttempt(out, phase as Phase, voice, open.attempt, usdOf(voice, res.usage) ?? 'unknown'); const s = readState(out); s.status = 'interrupted'; writeState(s); });
process.stdout.write(resultLineFor('interrupted', out) + '\n');
return EXIT.fail;
}
const used = res.usage;
const usage = isApi(voice) && pricingRow(modelOf(voice)) && typeof used.input_tokens === 'number' && typeof used.output_tokens === 'number' ? ['--usage-usd', String(estimateCostUsd({ input: used.input_tokens, output: used.output_tokens }, modelOf(voice)))] : [];
const usd = usdOf(voice, res.usage);
const usage = usd === undefined ? [] : ['--usage-usd', String(usd)];
try { withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage], true)); }
catch (e) {
if (voice !== 'outside' || !(e instanceof RunError)) throw e;
+28 -1
View File
@@ -31,7 +31,7 @@ import type { RunStatus } from './headless-artifacts';
import { sha256File } from './headless-artifacts';
import { outsideConsent } from './outside-consent';
import { familyConflict, modelFamily } from './outside-voice-runner';
import { renderSpend, reserve, settle, summarize, readLedger } from './spend-ledger';
import { SpendCapExceeded, release, renderSpend, reserve, settle, summarize, readLedger } from './spend-ledger';
import { DEFAULT_MAX_ROUNDS, attemptEstimate, overCap, resumeStop, roundCap, roundOf, roundsLine, stopForBudget, stopLines } from './autoplan-budget';
import { annotateConfirm, confirmBlock } from './autoplan-confirm';
@@ -413,6 +413,33 @@ export function closeOutside(state: RunState, phase: Phase, status: 'unavailable
return lines;
}
/** Raise an open attempt's reservation to what the call about to run can cost; the cap still binds (SPEND_CAP_EXCEEDED). */
export function fitReservation(out: string, phase: Phase, voice: Voice, attempt: string, usd: number): void {
const state = readState(out);
const a = voiceState(state, phase, voice).attempts?.find(x => x.attempt === attempt);
if (!a || usd <= a.estimate_usd + 1e-9) return;
const file = path.join(out, FILES.spend);
release(file, attempt);
try { reserve(file, attempt, usd); }
catch (e) {
reserve(file, attempt, a.estimate_usd);
if (e instanceof SpendCapExceeded) throw new RunError('SPEND_CAP_EXCEEDED', `${attempt} (${phase} ${voice}) was admitted at ${a.estimate_usd}; the call it is about to make can cost ${usd}, past the cap ${state.spend_cap_usd}`, 3);
throw e;
}
a.estimate_usd = usd;
writeState(state);
}
/** A paid call that failed (truncated, refused): settle what it cost and supersede its attempt, so a retry is admitted afresh. */
export function retireAttempt(out: string, phase: Phase, voice: Voice, attempt: string, usd: number | 'unknown'): void {
const state = readState(out);
const a = voiceState(state, phase, voice).attempts?.find(x => x.attempt === attempt);
if (a) { a.state = 'superseded'; a.terminal_at = now(); }
settle(path.join(out, FILES.spend), attempt, usd);
journal(out, { event: 'superseded', attempt, phase, voice, usage_usd: usd });
writeState(state);
}
/** Mark the outside voice of the current phase unavailable/failed (the runner could not run); closes the phase if native is bound. */
export function markOutside(out: string, phase: Phase, status: 'unavailable' | 'failed', detail: string): RunState {
const state = readState(out);
+16
View File
@@ -104,4 +104,20 @@ describe('gstack-autoplan run --native-runner api --runner api', () => {
expect(r.stderr).toMatch(/native voice unavailable: truncated: .*output limit/);
expect(r.state.status).toBe('interrupted');
}, 150_000);
test('a truncated call settles what it cost, and a retry with a larger output limit is admitted afresh against the cap', async () => {
mode = 'cut';
const first = await run('cut-retry', ['--spend-cap', '8']);
expect(first.state.status).toBe('interrupted');
const truncated = first.state.phases.ceo.native.attempts[0].attempt;
const ledger = JSON.parse(fs.readFileSync(path.join(first.out, 'spend.json'), 'utf8'));
expect(ledger.reserved.map((r: any) => r.attempt)).not.toContain(truncated);
expect(ledger.settled.find((s: any) => s.attempt === truncated).usd).toBeGreaterThan(0);
mode = 'ok';
calls = 0;
const retry = await run('cut-retry', ['--spend-cap', '8', '--max-tokens', '2000000']);
expect(retry.status).toBe(3);
expect(retry.stdout + retry.stderr).toContain('SPEND_CAP_EXCEEDED');
expect(calls).toBe(0);
}, 150_000);
});