fix(research-loop-cap): floor the turn cap before the guard, not after
A fractional TREKRESEARCH_MAX_CONV_TURNS below 1 cleared the `n <= 0` guard on its raw value and only then floored, so '0.5' and '0.9' became 0 and the budget became 0 x MAX_TOTAL_DIMENSIONS = 0: every turn denied, the loop silently dead rather than bounded. README.md:229 and docs/architecture.md:15 both promise that invalid values fall back to 3. docs/command-modes.md:42 enumerated "empty, non-numeric, zero, or negative" and happened to sidestep the case; the enumeration is now exhaustive about it. Measured before: '0.5' -> 0, '0.9' -> 0, '2.7' -> 2, '' / 'abc' / '-2' / '0' -> 3. Measured after: '0.5' -> 3, '0.9' -> 3, '2.7' -> 2, and 'Infinity' -> 3 (it is not a cap either). A cap of 0 is not a narrower cap, it is an off switch. The tests pin both directions: the fraction falls back, and allowTurn cannot report a budget of 0 under it. Review finding fc516799e6042e246a4b62d81903ac27c2efab84 (MINOR). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
This commit is contained in:
parent
066b9da1a2
commit
def6c05384
3 changed files with 38 additions and 5 deletions
|
|
@ -39,7 +39,7 @@ They run only at `effort: high` (resolved from the brief's `phase_signals`).
|
|||
| Env-var | Default | Behavior |
|
||||
|---------|---------|----------|
|
||||
| `VOYAGE_STORM_ENABLED` | _(unset — default-off)_ | `=1` grants the Phase 5 loop a non-zero turn budget and is the second condition on Phase 4.5's skip-guard. Unset, `research-loop-cap.mjs` grants 0 turns and **both** phases are inert: doing nothing keeps the whole mechanism off. |
|
||||
| `TREKRESEARCH_MAX_CONV_TURNS` | `3` | Max turns per under-illuminated dimension. Budget = this × `maxDimensions` (8, `settings.json:16`). Empty, non-numeric, zero, or negative values fall back to `3` — never to unbounded. |
|
||||
| `TREKRESEARCH_MAX_CONV_TURNS` | `3` | Max turns per under-illuminated dimension. Budget = this × `maxDimensions` (8, `settings.json:16`). Empty, non-numeric, zero, negative, `Infinity`, or any fraction that floors below `1` (`0.5`, `0.9`) fall back to `3` — never to unbounded, and never to `0`. A fraction at or above `1` floors (`2.7` → `2`). |
|
||||
| `VOYAGE_DISABLE_CAP_HOOK` | _(unset)_ | `=1` disables `hooks/scripts/pre-agent-cap.mjs`, the `PreToolUse` enforcement of the turn budget. The cap primitive still applies; only the second gate is switched off. |
|
||||
|
||||
Adoption of the loop as a default is gated on a pre-registered measurement —
|
||||
|
|
|
|||
|
|
@ -43,15 +43,22 @@ export function isStormEnabled(env = process.env) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Coerce TREKRESEARCH_MAX_CONV_TURNS. NaN, empty, negative, or zero all fall
|
||||
* back to MAX_CONV_TURNS — never to unbounded.
|
||||
* 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 = Number(raw);
|
||||
const n = Math.floor(Number(raw));
|
||||
if (!Number.isFinite(n) || n <= 0) return MAX_CONV_TURNS;
|
||||
return Math.floor(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -211,6 +211,32 @@ test('resolveMaxConvTurns — valid positive integer string is honored', () => {
|
|||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2' }), 2);
|
||||
});
|
||||
|
||||
// A fractional value passed the `n <= 0` guard and only THEN floored, so 0.5 and
|
||||
// 0.9 became 0 and the budget became 0 × 8 = 0 — every turn denied, the loop
|
||||
// silently dead, while README.md and docs/architecture.md both promise a
|
||||
// fallback of 3. The guard has to see the floored value, not the raw one.
|
||||
test('resolveMaxConvTurns — a fractional value below 1 falls back to the default, never 0', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.5' }), MAX_CONV_TURNS);
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.9' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — a fractional value above 1 still floors (2.7 → 2)', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2.7' }), 2);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — Infinity is not a cap and falls back to the default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'Infinity' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('allowTurn — a fractional cap below 1 cannot produce a budget of 0', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '0.5' };
|
||||
const r = allowTurn({ runId: 'r-frac', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true, 'a budget of 0 would make the loop silently dead, not bounded');
|
||||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- pure-core unit coverage --------------------------------------------------
|
||||
|
||||
test('isStormEnabled — only the literal string "1" enables', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue