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
299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
// lib/util/research-loop-cap.mjs
|
||
// Stateful, default-off cost cap for the /trekresearch bounded conversation
|
||
// loop (Phase 4.5 dimension discovery + Phase 5 loop turns).
|
||
//
|
||
// Three properties the plan review required:
|
||
// (a) Default-off — VOYAGE_STORM_ENABLED must be '1'; otherwise the budget
|
||
// is 0 regardless of effort. This IS the decline branch: doing nothing
|
||
// leaves the mechanism off, and adopt is flipping this one constant.
|
||
// (b) The cap counts itself — allowTurn() derives used-turn count from an
|
||
// append-only JSONL ledger, never from a caller-supplied number. A cap
|
||
// that asks the caller how many turns it has used is not a cap. Each
|
||
// grant additionally claims a turn SLOT with O_EXCL, so the bound holds
|
||
// when several callers decide at once instead of only when they queue.
|
||
// (c) Correct size bound — worst case is max_conv_turns × max_total_dimensions,
|
||
// where max_total_dimensions is the WHOLE list (interview + discovered)
|
||
// under settings.json:16's cap of 8 — not × discovered-only.
|
||
//
|
||
// CLAUDE_PLUGIN_DATA absent => fall back to ~/.claude/voyage. The variable is
|
||
// EMPTY in the Bash tool's process env (measured in a live plugin-enabled
|
||
// session), and the Phase 5 bash snippet is this module's only caller — so
|
||
// denying on its absence denied turn 1 of every real run. The root is resolved
|
||
// in code rather than demanded of the environment, and hooks/scripts/
|
||
// pre-agent-cap.mjs resolves it through the SAME function, so the writer and
|
||
// the reader can never disagree about where the ledger lives.
|
||
//
|
||
// The fail-closed stance covers both directions of ledger IO: a ledger that
|
||
// cannot be WRITTEN denies the turn, and a ledger that exists but cannot be
|
||
// READ denies it too. Only ENOENT counts as zero turns spent, because that is
|
||
// the legitimate first-turn state. This module is a budget control, not
|
||
// telemetry — the opposite of lib/stats/event-emit.mjs's fail-open.
|
||
//
|
||
// CLI shim:
|
||
// node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E
|
||
// → JSON: { ok, used, budget, reason? } (exit 0 = granted, exit 1 = denied)
|
||
|
||
import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { homedir } from 'node:os';
|
||
|
||
export const MAX_CONV_TURNS = 3;
|
||
export const MAX_TOTAL_DIMENSIONS = 8; // settings.json:16 maxDimensions — whole list, not discovered-only
|
||
|
||
const LEDGER_FILENAME = 'trekresearch-loop-ledger.jsonl';
|
||
const CLAIM_DIRNAME = 'trekresearch-loop-claims';
|
||
|
||
export function isStormEnabled(env = process.env) {
|
||
return env.VOYAGE_STORM_ENABLED === '1';
|
||
}
|
||
|
||
/**
|
||
* Coerce TREKRESEARCH_MAX_CONV_TURNS. NaN, empty, negative, zero, Infinity, or
|
||
* any fraction that floors below 1 all fall back to MAX_CONV_TURNS — never to
|
||
* unbounded, and never to 0.
|
||
*
|
||
* The floor is applied BEFORE the `<= 0` guard, not after. Flooring afterwards
|
||
* let '0.5' and '0.9' clear a guard written against the raw value and then
|
||
* become 0, making the budget 0 × MAX_TOTAL_DIMENSIONS = 0: every turn denied
|
||
* and the loop silently dead rather than bounded. A cap of 0 is not a narrower
|
||
* cap, it is an off switch that the documented fallback promises not to be.
|
||
*/
|
||
export function resolveMaxConvTurns(env = process.env) {
|
||
const raw = env.TREKRESEARCH_MAX_CONV_TURNS;
|
||
if (raw === undefined || raw === null || raw === '') return MAX_CONV_TURNS;
|
||
const n = Math.floor(Number(raw));
|
||
if (!Number.isFinite(n) || n <= 0) return MAX_CONV_TURNS;
|
||
return n;
|
||
}
|
||
|
||
/**
|
||
* The one data root for everything this loop writes: the turn ledger and the
|
||
* PreToolUse scope marker. CLAUDE_PLUGIN_DATA when the harness provides it,
|
||
* ~/.claude/voyage when it does not — which is the case in every Bash tool
|
||
* invocation today.
|
||
*/
|
||
export function resolveDataRoot(env = process.env) {
|
||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||
if (dir && typeof dir === 'string' && dir.length > 0) return dir;
|
||
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
|
||
return join(home, '.claude', 'voyage');
|
||
}
|
||
|
||
export function resolveLedgerPath(env = process.env) {
|
||
return join(resolveDataRoot(env), LEDGER_FILENAME);
|
||
}
|
||
|
||
/**
|
||
* Where the per-turn claim files live. A claim is the ATOMIC record that a turn
|
||
* slot is taken; the ledger is the readable record of what that turn was for.
|
||
*
|
||
* The claim exists because the ledger alone cannot bound the loop. Counting the
|
||
* ledger and then appending is read-then-write: N callers that all observe
|
||
* `used == budget - 1` all decide to grant, and the bound is exceeded by N-1 —
|
||
* precisely the concurrent case (several agents spawned in one message) that
|
||
* allowTurn's own comment named as the reason it had to be append-only.
|
||
*
|
||
* Claim files are empty, at most `budget` per run, and never cleaned up — the
|
||
* same standing as the ledger itself, which also grows for the life of the data
|
||
* root. Two consequences worth stating rather than discovering: reusing a
|
||
* runId across runs finds its slots already taken and denies, and two runIds
|
||
* that collide after filename sanitisation block each other. Both err toward
|
||
* denying a turn, which is the safe direction for a budget control.
|
||
*/
|
||
export function resolveClaimDir(env = process.env) {
|
||
return join(resolveDataRoot(env), CLAIM_DIRNAME);
|
||
}
|
||
|
||
function claimFileName(runId, slot) {
|
||
return `${String(runId).replace(/[^A-Za-z0-9._-]/g, '_')}-${slot}.claim`;
|
||
}
|
||
|
||
/**
|
||
* Try to take turn slot `slot` for `runId`. `wx` is O_CREAT|O_EXCL: the kernel
|
||
* decides the winner, so exactly one caller can ever create a given slot file.
|
||
*
|
||
* @returns {boolean} true when this caller took the slot, false when it was already taken
|
||
* @throws on any IO error other than EEXIST — the caller turns that into a denial
|
||
*/
|
||
function claimSlot(claimDir, runId, slot) {
|
||
try {
|
||
writeFileSync(join(claimDir, claimFileName(runId, slot)), '', { flag: 'wx' });
|
||
return true;
|
||
} catch (e) {
|
||
if (e && e.code === 'EEXIST') return false;
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read one run's turn count off the append-only ledger.
|
||
*
|
||
* ENOENT is 0 turns spent — the legitimate first-turn state, and the reason
|
||
* this cannot simply throw on every read failure. Every OTHER read error
|
||
* (EISDIR, EACCES, EIO) THROWS, because returning 0 from an unreadable ledger
|
||
* re-granted the full budget on every call: unbounded, and the exact
|
||
* silently-grant-unlimited failure this module's header argues against three
|
||
* lines above the code that did it. The missing-directory case already failed
|
||
* closed; this makes the unreadable-file case agree with it.
|
||
*
|
||
* The `existsSync` pre-check is deliberately gone: readFileSync's own ENOENT
|
||
* carries the same information without a second syscall that can disagree with
|
||
* the read that follows it.
|
||
*
|
||
* Exported so hooks/scripts/pre-agent-cap.mjs counts through this exact
|
||
* 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, exhausted: number}}
|
||
* @throws when the ledger exists but cannot be read
|
||
*/
|
||
export function readLedger(ledgerPath, runId) {
|
||
let text;
|
||
try {
|
||
text = readFileSync(ledgerPath, 'utf-8');
|
||
} catch (e) {
|
||
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 {
|
||
const rec = JSON.parse(line);
|
||
if (rec.runId !== runId) continue;
|
||
if (rec.exhausted === true) exhausted++;
|
||
else granted++;
|
||
} catch { /* skip malformed lines */ }
|
||
}
|
||
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 */ }
|
||
}
|
||
|
||
/**
|
||
* Decide whether one more research-loop turn may run.
|
||
*
|
||
* Phase 4.5/5 may spawn several agents in a single message, so the decision has
|
||
* to survive concurrent callers. It does that by CLAIMING a turn slot with
|
||
* O_EXCL (see claimSlot) and only then appending to the ledger. The comment
|
||
* that used to sit here asserted "append-only: never read-modify-write" as if
|
||
* appending were itself the concurrency guarantee — but the decision path was
|
||
* count-then-append, which is read-then-write, so the claim was unsupported by
|
||
* the code beneath it. The kernel now picks the winner for each slot.
|
||
*
|
||
* @param {{runId: string, dimension: string, effort: string}} args
|
||
* @param {{env?: object, now?: Date}} [opts]
|
||
* @returns {{ok: boolean, used: number, budget: number, reason?: string}}
|
||
*/
|
||
export function allowTurn({ runId, dimension, effort } = {}, opts = {}) {
|
||
const env = opts.env || process.env;
|
||
const now = opts.now || new Date();
|
||
|
||
if (!isStormEnabled(env)) {
|
||
return { ok: false, used: 0, budget: 0, reason: 'storm_disabled' };
|
||
}
|
||
if (effort !== 'high') {
|
||
return { ok: false, used: 0, budget: 0, reason: 'effort_not_high' };
|
||
}
|
||
if (!runId || !dimension) {
|
||
return { ok: false, used: 0, budget: 0, reason: 'missing_args' };
|
||
}
|
||
|
||
const maxConvTurns = resolveMaxConvTurns(env);
|
||
const budget = maxConvTurns * MAX_TOTAL_DIMENSIONS;
|
||
|
||
const ledgerPath = resolveLedgerPath(env);
|
||
let ledger;
|
||
try {
|
||
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
|
||
// the total number of grants for a run can never exceed the budget however
|
||
// many callers arrive at once.
|
||
const claimDir = resolveClaimDir(env);
|
||
let slot = used + 1;
|
||
let claimed = false;
|
||
try {
|
||
mkdirSync(claimDir, { recursive: true });
|
||
while (slot <= budget) {
|
||
if (claimSlot(claimDir, runId, slot)) { claimed = true; break; }
|
||
slot++;
|
||
}
|
||
} catch (e) {
|
||
return { ok: false, used, budget, reason: `claim-failed: ${e.message}` };
|
||
}
|
||
if (!claimed) {
|
||
markExhausted(ledgerPath, runId, now);
|
||
return { ok: false, used, budget, reason: 'budget_exhausted' };
|
||
}
|
||
|
||
try {
|
||
const dir = dirname(ledgerPath);
|
||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||
appendFileSync(ledgerPath, JSON.stringify({ ts: now.toISOString(), runId, dimension, effort, slot }) + '\n');
|
||
} catch (e) {
|
||
return { ok: false, used, budget, reason: `ledger-write-failed: ${e.message}` };
|
||
}
|
||
|
||
return { ok: true, used: slot, budget };
|
||
}
|
||
|
||
// ---- CLI shim ----------------------------------------------------------------
|
||
|
||
function parseArgs(argv) {
|
||
const out = {};
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--run-id') out.runId = argv[++i];
|
||
else if (a === '--dimension') out.dimension = argv[++i];
|
||
else if (a === '--effort') out.effort = argv[++i];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
if (!args.runId || !args.dimension || !args.effort) {
|
||
process.stdout.write(JSON.stringify({
|
||
ok: false,
|
||
reason: 'usage: research-loop-cap.mjs --run-id ID --dimension D --effort standard|high|low',
|
||
}) + '\n');
|
||
process.exit(1);
|
||
}
|
||
const result = allowTurn(args);
|
||
process.stdout.write(JSON.stringify(result) + '\n');
|
||
process.exit(result.ok ? 0 : 1);
|
||
}
|