fix(cap): fail closed when the ledger cannot be READ, in both modules
An unreadable ledger returned 0 from countTurns in BOTH the primitive and the hook, so a run whose ledger existed but could not be read (EISDIR, EACCES, EIO) was handed the full budget again on every call - unbounded. research-loop-cap.mjs argues against exactly that three lines above the code that did it, and its missing-DIRECTORY case already failed closed. The unreadable-FILE case now agrees with it. Only ENOENT still counts as zero turns spent: that is the legitimate first-turn state, and the reason this cannot just throw on any read failure. The hook no longer carries its own countTurns. It imports the primitive's exported readLedger(), the same way it already resolves the data root through resolveDataRoot() - a reader and a writer with private copies of the counting rule is how a hook ends up enforcing a different bound than the gate it backs. In scope + cannot count now exits 2 with a message that says counting failed, not that the budget is spent. Fail-closed stays scoped to the loop: a test pins that an unreadable ledger in an OUT-of-scope session still exits 0, because a PreToolUse hook that over-blocks bricks every session on the box. Also dropped the existsSync pre-check before the read - readFileSync's own ENOENT carries the same information without a second syscall that can disagree with the read that follows it. Review finding 5e1c6230f48ead38fa77cd8f4b06bfdc2b5b7bbf (MINOR). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
This commit is contained in:
parent
def6c05384
commit
d2b6a696bd
6 changed files with 161 additions and 32 deletions
|
|
@ -21,9 +21,11 @@
|
|||
// 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 stays where it is still real: a ledger that cannot be
|
||||
// written denies the turn. This module is a budget control, not telemetry —
|
||||
// the opposite of lib/stats/event-emit.mjs's fail-open.
|
||||
// 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
|
||||
|
|
@ -78,20 +80,48 @@ export function resolveLedgerPath(env = process.env) {
|
|||
return join(resolveDataRoot(env), LEDGER_FILENAME);
|
||||
}
|
||||
|
||||
function countTurns(ledgerPath, runId) {
|
||||
if (!existsSync(ledgerPath)) return 0;
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {string} ledgerPath
|
||||
* @param {string} runId
|
||||
* @returns {{granted: number}}
|
||||
* @throws when the ledger exists but cannot be read
|
||||
*/
|
||||
export function readLedger(ledgerPath, runId) {
|
||||
let text;
|
||||
try { text = readFileSync(ledgerPath, 'utf-8'); }
|
||||
catch { return 0; }
|
||||
let count = 0;
|
||||
try {
|
||||
text = readFileSync(ledgerPath, 'utf-8');
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT') return { granted: 0 };
|
||||
const err = new Error(`ledger unreadable at ${ledgerPath}: ${e.message}`);
|
||||
err.code = 'VOYAGE_LEDGER_UNREADABLE';
|
||||
throw err;
|
||||
}
|
||||
let granted = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const rec = JSON.parse(line);
|
||||
if (rec.runId === runId) count++;
|
||||
if (JSON.parse(line).runId === runId) granted++;
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
return count;
|
||||
return { granted };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -122,7 +152,12 @@ export function allowTurn({ runId, dimension, effort } = {}, opts = {}) {
|
|||
const budget = maxConvTurns * MAX_TOTAL_DIMENSIONS;
|
||||
|
||||
const ledgerPath = resolveLedgerPath(env);
|
||||
const used = countTurns(ledgerPath, runId);
|
||||
let used;
|
||||
try {
|
||||
used = readLedger(ledgerPath, runId).granted;
|
||||
} catch (e) {
|
||||
return { ok: false, used: 0, budget, reason: `ledger-read-failed: ${e.message}` };
|
||||
}
|
||||
if (used >= budget) {
|
||||
return { ok: false, used, budget, reason: 'budget_exhausted' };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue