diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index a7b3194ea..026f9c5d4 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -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 + 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 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 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; diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index 460f15dad..a20d0615c 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -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); diff --git a/test/autoplan-run-api.test.ts b/test/autoplan-run-api.test.ts index b35cc2c3c..625480be9 100644 --- a/test/autoplan-run-api.test.ts +++ b/test/autoplan-run-api.test.ts @@ -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); });