From d5bf54316d37061bde7798ae3193c8c29cb9a8a9 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 19:29:05 +0000 Subject: [PATCH] autoplan shadow: reply files never follow a link; the retry reserves against the current cap - a model reply is written by temp file and rename, so a link at the reply path is replaced, not written through - each shadow reservation reads the run's current spend cap, so a budget change during the call is not undone Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/autoplan-shadow.ts | 4 ++-- test/autoplan-shadow.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/autoplan-shadow.ts b/lib/autoplan-shadow.ts index 9b055c4e0..ca57f99ba 100644 --- a/lib/autoplan-shadow.ts +++ b/lib/autoplan-shadow.ts @@ -308,7 +308,7 @@ export async function runShadow(out: string, claim: ShadowClaim, o: RunShadowOpt const remaining = deadline - Date.now(); if (remaining <= 0) throw new ShadowFailure('timeout', `the ${(o.deadlineMs ?? SHADOW_DEADLINE_MS) / 1000} s deadline passed before the call`); const attempt = `shadow-r${rev}-${callIds(readJournal(out), rev).length + 1}`; - try { reserve(ledgerFile, attempt, estimate(maxTokens), { cap: state.spend_cap_usd, label: 'shadow' }); } + try { reserve(ledgerFile, attempt, estimate(maxTokens), { cap: readState(out).spend_cap_usd, label: 'shadow' }); } catch (e: any) { throw new ShadowFailure('budget', e.message); } journal(out, { event: 'shadow_dispatched', gate_rev: rev, attempt, model, max_tokens: maxTokens, estimate_usd: estimate(maxTokens), retrospective: claim.retrospective }); let result: ApiCallResult; @@ -322,7 +322,7 @@ export async function runShadow(out: string, claim: ShadowClaim, o: RunShadowOpt const { input_tokens: inTok, output_tokens: outTok } = result.usage; const usd: number | 'unknown' = typeof inTok === 'number' && typeof outTok === 'number' ? estimateCostUsd({ input: inTok, output: outTok }, model) : 'unknown'; settle(ledgerFile, attempt, usd); - fs.writeFileSync(path.join(shadowDir(out), `${attempt}.reply.txt`), result.text); + writeAtomic(path.join(shadowDir(out), `${attempt}.reply.txt`), result.text); const reply = { result, usd, attempt }; replies.push(reply); return reply; diff --git a/test/autoplan-shadow.test.ts b/test/autoplan-shadow.test.ts index 65f93e1ce..5ac342799 100644 --- a/test/autoplan-shadow.test.ts +++ b/test/autoplan-shadow.test.ts @@ -216,6 +216,32 @@ describe('runShadow: retry, truncation, failures and the deadline', async () => expect(readLedger(path.join(out, 'spend.json')).settled.filter(s => s.label === 'shadow').map(s => [s.attempt, s.usd])).toEqual([['shadow-r1-1', 0.014]]); expect(fs.existsSync(path.join(out, 'shadow', 'r1.pending'))).toBe(false); }); + test('a link at a reply path is replaced, never written through', async () => { + const f = await fake('linked-reply', [{ mode: 'valid' }]); + const out = gateRun(); + const { claim } = claimShadow(out); + const sentinel = path.join(TMP, 'sentinel.txt'); + fs.writeFileSync(sentinel, 'keep'); + const reply = path.join(out, 'shadow', 'shadow-r1-1.reply.txt'); + fs.symlinkSync(sentinel, reply); + expect((await runShadow(out, claim!, { env: { ...process.env, ...f.env } })).status).toBe('recorded'); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('keep'); + expect(fs.lstatSync(reply).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(reply, 'utf8')).toContain('fake prediction'); + }); + test('a cap changed during the call admits the retry against the new cap and is not written back over', async () => { + const f = await fake('cap-moved', [{ mode: 'truncated', delay_ms: 400 }, { mode: 'valid' }]); + const out = gateRun(); + const { claim } = claimShadow(out); + const pending = runShadow(out, claim!, { env: { ...process.env, ...f.env } }); + await Bun.sleep(150); + const run = readRun(out); + run.spend_cap_usd = 2; + fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n'); + expect((await pending).status).toBe('recorded'); + expect(f.requests().length).toBe(2); + expect(readLedger(path.join(out, 'spend.json')).cap_usd).toBe(2); + }); test('a malformed page is asked once more; items valid in either reply record, the rest record malformed', async () => { const fixed = await shadowed('missing-then-valid', [{ mode: 'missing' }, { mode: 'valid' }]); expect(fixed.f.requests().length).toBe(2);