fix(cap): move the enforcement boundary to a denial tombstone, not the turn count
allowTurn() appends BEFORE the turn runs, so during granted turn N the
ledger holds N records. The hook denied at `used >= budget`, which blocked
every tool call of the FINAL granted turn: the primitive granted B turns
and the harness permitted B-1. Worse, an exhausted run therefore always
terminated through an exit-2 tool denial instead of the graceful "cap
exhausted" exit at commands/trekresearch.md - and the prose says in as
many words that exit 2 is not exit 1, so the model was pushed out through
the one exit it is told NOT to treat as a cap.
The review recommended denying at `used > budget`. Taken alone that fixes
the count and breaks the hook: once the O_EXCL claim (previous commit)
makes a breached ledger impossible, `granted > budget` can no longer fire,
and the case this hook exists for - the loop consults the gate, is denied,
and issues the tool call anyway - would be allowed. A deny branch that
cannot be reached is a dead security claim, which is the same thing S82
removed two of rather than leave standing.
So the denial itself became a record. allowTurn() appends a tombstone
{runId, exhausted: true} when it denies for budget, and the hook denies on
the tombstone. Both properties now hold at once:
granted == budget, no tombstone -> turn B is in flight -> ALLOW
tombstone present -> the gate already said no -> DENY
granted > budget -> breached, any cause -> DENY
A tombstone is not a turn: readLedger reports {granted, exhausted}
separately so it can never consume budget. allowTurn short-circuits on an
existing tombstone, so a hammered gate neither re-walks every slot nor
grows the ledger. The tombstone write is best effort on purpose - the
denial is already the correct answer, so a ledger that cannot take the
record must not turn a denial into a grant.
The parallel-boundary test now asserts GRANTED turns rather than raw
ledger lines, because the denied callers legitimately add tombstones.
Review finding 8eb53458ac3efec778094f9f03b09e1cc1077a09 (MINOR).
Operator decision: tombstone over the literal recommended_action.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
This commit is contained in:
parent
156539204a
commit
32e20fc0dc
6 changed files with 180 additions and 33 deletions
|
|
@ -144,9 +144,15 @@ function claimSlot(claimDir, runId, slot) {
|
|||
* function. A reader and a writer with private copies of the counting rule are
|
||||
* how a hook ends up enforcing a different bound than the gate it backs.
|
||||
*
|
||||
* Two counts, deliberately separate. `granted` is turns handed out. `exhausted`
|
||||
* is tombstones — records this gate wrote when it DENIED a turn. A tombstone is
|
||||
* not a turn and must never consume budget; it exists so the PreToolUse hook can
|
||||
* tell "turn B is in flight" (granted == budget, no tombstone) apart from "the
|
||||
* gate already said no and something kept going" (tombstone present).
|
||||
*
|
||||
* @param {string} ledgerPath
|
||||
* @param {string} runId
|
||||
* @returns {{granted: number}}
|
||||
* @returns {{granted: number, exhausted: number}}
|
||||
* @throws when the ledger exists but cannot be read
|
||||
*/
|
||||
export function readLedger(ledgerPath, runId) {
|
||||
|
|
@ -154,19 +160,36 @@ export function readLedger(ledgerPath, runId) {
|
|||
try {
|
||||
text = readFileSync(ledgerPath, 'utf-8');
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT') return { granted: 0 };
|
||||
if (e && e.code === 'ENOENT') return { granted: 0, exhausted: 0 };
|
||||
const err = new Error(`ledger unreadable at ${ledgerPath}: ${e.message}`);
|
||||
err.code = 'VOYAGE_LEDGER_UNREADABLE';
|
||||
throw err;
|
||||
}
|
||||
let granted = 0;
|
||||
let exhausted = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
if (JSON.parse(line).runId === runId) granted++;
|
||||
const rec = JSON.parse(line);
|
||||
if (rec.runId !== runId) continue;
|
||||
if (rec.exhausted === true) exhausted++;
|
||||
else granted++;
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
return { granted };
|
||||
return { granted, exhausted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that this run has been denied a turn for budget.
|
||||
*
|
||||
* Best effort on purpose: the denial itself is already the correct answer, so a
|
||||
* ledger that cannot take the tombstone must not turn a denial into a grant. The
|
||||
* tombstone only strengthens the harness-level backstop.
|
||||
*/
|
||||
function markExhausted(ledgerPath, runId, now) {
|
||||
try {
|
||||
appendFileSync(ledgerPath, JSON.stringify({ ts: now.toISOString(), runId, exhausted: true }) + '\n');
|
||||
} catch { /* best effort — see above */ }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -202,12 +225,19 @@ export function allowTurn({ runId, dimension, effort } = {}, opts = {}) {
|
|||
const budget = maxConvTurns * MAX_TOTAL_DIMENSIONS;
|
||||
|
||||
const ledgerPath = resolveLedgerPath(env);
|
||||
let used;
|
||||
let ledger;
|
||||
try {
|
||||
used = readLedger(ledgerPath, runId).granted;
|
||||
ledger = readLedger(ledgerPath, runId);
|
||||
} catch (e) {
|
||||
return { ok: false, used: 0, budget, reason: `ledger-read-failed: ${e.message}` };
|
||||
}
|
||||
const used = ledger.granted;
|
||||
|
||||
// Already tombstoned: this run is over. Short-circuit so a hammered gate
|
||||
// neither walks every slot again nor appends a second tombstone.
|
||||
if (ledger.exhausted > 0) {
|
||||
return { ok: false, used, budget, reason: 'budget_exhausted' };
|
||||
}
|
||||
// Claim a turn SLOT before spending anything. The ledger count only says
|
||||
// where to start looking; the claim is what makes the grant exclusive. Slot
|
||||
// numbers are bounded by `budget`, and each can be created exactly once, so
|
||||
|
|
@ -226,6 +256,7 @@ export function allowTurn({ runId, dimension, effort } = {}, opts = {}) {
|
|||
return { ok: false, used, budget, reason: `claim-failed: ${e.message}` };
|
||||
}
|
||||
if (!claimed) {
|
||||
markExhausted(ledgerPath, runId, now);
|
||||
return { ok: false, used, budget, reason: 'budget_exhausted' };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue