diff --git a/lib/autoplan-shadow.ts b/lib/autoplan-shadow.ts index dbc1752e1..f00ae3b6b 100644 --- a/lib/autoplan-shadow.ts +++ b/lib/autoplan-shadow.ts @@ -417,8 +417,12 @@ function publish(out: string, claim: ShadowClaim, r: { rows?: Array { + if (!resultRow(readJournal(out), rev) && !fs.existsSync(recordFile(out, rev))) journal(out, { event: 'shadow_failed', gate_rev: rev, code, detail: String(e?.message ?? e).slice(0, 500), attempts, usd, retrospective: claim.retrospective }); + dropLease(out, rev); + }, { waitMs: 2_000 }); + } catch { /* still locked: the lease expires and the next command settles the revision */ } return { status: 'failed', code, line: `SHADOW: failed ${code} (${e?.message ?? e}); the gate stands` }; } } diff --git a/test/autoplan-shadow.test.ts b/test/autoplan-shadow.test.ts index d7ce8275a..7af295ee6 100644 --- a/test/autoplan-shadow.test.ts +++ b/test/autoplan-shadow.test.ts @@ -292,6 +292,15 @@ describe('runShadow: retry, truncation, failures and the deadline', async () => expect(claimShadow(out)).toEqual({}); expect(f.requests().length).toBe(1); }); + test('runShadow never throws when the journal cannot be appended', async () => { + const f = await fake('journal-readonly', [{ mode: 'valid' }]); + const out = gateRun(); + const { claim } = claimShadow(out); + fs.chmodSync(path.join(out, 'attempts.jsonl'), 0o444); + try { + expect(await runShadow(out, claim!, { env: { ...process.env, ...f.env } })).toMatchObject({ status: 'failed' }); + } finally { fs.chmodSync(path.join(out, 'attempts.jsonl'), 0o644); } + }); test('a record already published makes a later claim a no-op: no second call is paid', async () => { const { f, out } = await shadowed('noop', [{ mode: 'valid' }]); const before = fs.readFileSync(recordFile(out, 1));