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
|
|
@ -495,9 +495,11 @@ the failure to the operator and run the loop. The reverse — skipping the budge
|
|||
gate because a marker exists — is never allowed.
|
||||
|
||||
**Removal belongs to every exit below, especially the exhausted one.** The hook
|
||||
denies `WebSearch`/`WebFetch`/`Task` once the budget is spent, and it keeps
|
||||
denying for as long as the marker is there — including Phase 6, which spawns
|
||||
agents. A marker that outlives the loop turns a bound on this loop into a brick
|
||||
denies `WebSearch`/`WebFetch`/`Task` once `research-loop-cap.mjs` has denied a
|
||||
turn — the gate records its own denials, so the LAST granted turn still runs its
|
||||
queries and exhaustion reaches you through exit 2 of the budget gate below, not
|
||||
through a blocked tool call. Once denied, the hook keeps denying for as long as
|
||||
the marker is there — including Phase 6, which spawns agents. A marker that outlives the loop turns a bound on this loop into a brick
|
||||
on the rest of the session. Cleanup covers the three exits and nothing else: a
|
||||
crashed session runs no cleanup at all, and is covered instead by the hook's
|
||||
TTL (default 6h, `VOYAGE_CAP_SCOPE_TTL_MS`), which auto-resets a stale marker.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue