fix(babysit): stop the gated ack contradicting itself at max_cycles=0 - #8106
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS One-line guard fixes the ack at the correct layer — engine and skill already define 0 as unlimited (verified at [DESIGN-REVIEWED] 83e4e0b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified. The fix, the class audit, the engine semantics, and the skill documentation all check out against the repository. Here is the review: First-Principles-Verdict: PASS A one-guard fix that makes the ack stop asserting a 0-cap and no-cap at once; nothing rides along, nothing new is added. What this change shipsIntent: make the arming acknowledgement for an unlimited gated loop describe its cycle cap exactly once, so the model reading it knows no bound exists. This is a FIX.
No new capability, config key, flag, or public surface is introduced, so there is nothing to consumer-count. The class audit in the description reproduces: grepping [FIRST-PRINCIPLES-REVIEWED] 83e4e0b |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
This branch never touched The line the finding anchors on is main's own, from The mechanism is nonetheless real against main, and I checked rather than dismissing it. Declining to fix it here is a scope judgement, not a waiver. This PR is a 33-line ack-string fix that cannot introduce or regress that path, and editing [GPT-REVIEWED] 58c23af |
Done, and the remedy this lane asked for is the one applied. The branch is rebased onto current The lane's reading was right about the bytes and wrong about the authorship, and the distinction matters for the record. Every The description mismatch the lane flagged is also gone. The body no longer claims only what it happens to touch by luck; it names the two files, states which side of the [FIRST-PRINCIPLES-REVIEWED] 58c23af |
A gated monitor loop armed with an explicit max_cycles=0 (unlimited) got an acknowledgement that asserted both a cap and no cap in one sentence: the gated cadence clause rendered "the 0 cap counts delivered turns" while the shared tail appended ", with NO cycle cap" for the same falsy value. The ack is what the model reads back to learn the loop's semantics, and the cycle cap is the runaway backstop. Contradicting itself on exactly the parameter that decides whether an unattended loop can spend without limit leaves the agent unable to tell whether a bound exists. Gate the cadence clause on max_cycles being truthy so the unlimited case is described once, by the tail that already says it. This makes the ack agree with the engine, where a falsy max_cycles skips the cap check entirely, and with the babysit skill, which documents 0 as unlimited for callers who ask for an unbounded loop. Documented behaviour is unchanged, so no spec moves. Two tests pin both sides of the contradiction: the unlimited loop must positively declare it has no cap and must not claim one counts turns, and a bounded loop must state its cap and not deny it.
58c23af to
83e4e0b
Compare
NicholasRBowers
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix with clear root cause -- the monitor_start ack rendered both the cap-counts clause and the NO-cycle-cap clause when max_cycles=0; the cadence clause now shares the tail's truthiness guard (message text only, no gating-logic change).
Fixes #8100.
Problem
When
monitor_startarmed a GATED loop with an explicitmax_cycles=0(documented as "unlimited"), the acknowledgement contradicted itself in one sentence. The gated cadence clause insrc/kiro_crew/mcp_tools/control.pyunconditionally renderedthe {max_cycles or 0} cap counts delivered turnswhile the shared tail appended, with NO cycle capfor the same falsy value, so the agent read both a 0-cap and a no-cap.This matters because the ack is what the model reads back to learn the loop's semantics, and the cycle cap is the runaway backstop. An ack that asserts both a cap and no cap leaves the agent unable to tell whether a bound exists on exactly the parameter that decides whether an unattended loop can spend without limit.
Fix
Gate the cadence clause on
max_cyclesbeing truthy, so the falsy case is described exactly once by the existing, with NO cycle captail:The ungated branch was unaffected and left unchanged.
Which side was wrong, and why the ack moved rather than the engine
0genuinely means unlimited here, on both of the surfaces that define it, so the ack was the wrong one:autonudge.py's cap check isif loop.max_cycles and loop.cycle_count >= loop.max_cycles, so a falsy cap is never enforced. A0loop runs untilautonudge_stop, the STOP sentinel, ormax_runtime_secs.max_cyclesas a runaway backstop rather than a finish line, and says to "pass0for unlimited only when the user explicitly asks for an unbounded loop".So the engine and the documented contract already agree that
0is unlimited-on-purpose; only the ack disagreed with them. Making0refuse would be a behaviour change to a documented escape hatch, and making the message vaguer would hide the one fact the reader needs. Documented behaviour is therefore unchanged, and no spec or SKILL.md moves with this commit.Class audit — one site, not an instance fix
Both clauses were grepped repo-wide (
NO cycle cap,cap counts,max_cycles or 0):mcp_tools/control.py:963(gated cadence)mcp_tools/control.py:969(shared tail)if max_cycles else ", with NO cycle cap"dashboard/session_directive_apply.py:238(directive applier ack)There is no second site that can render the contradiction, so this closes the class.
Test
test/test_monitor_start_ack.pygains two tests pinning both sides:test_a_gated_loop_with_zero_max_cycles_does_not_contradict_itself— an unlimited gated loop must positively declareNO cycle capand must not also claimcap counts. Asserting the positive matters: mere silence would let a bounded-sounding ack ship for a loop that can spend without limit.test_a_gated_loop_with_a_truthy_max_cycles_states_the_cap— the counterpart, so the bounded branch is pinned from the other side and the two clauses can never both appear.Confirmed the first fails without the fix and passes with it.
Pattern harvest
The two clauses describing one parameter sat ~6 lines apart in a single chained-
+expression, and only one of them had the truthiness guard.max_cycles or 0reads as defensive normalisation, which is exactly why the missing guard survived review: it looks like the falsy case was handled, when it only coercedNoneinto a value that then rendered as a false claim.Rule candidate: when two fragments of one user-facing message describe the same parameter, every fragment must share the parameter's falsy guard — an
x or 0in a template is a rendering default, never evidence the empty case was considered. Too narrow to encode as a lint (there is no way to tell which fragments describe the same value), and the general form — "no hardcoded user-facing strings, one owner per limit" — is already in AGENTS.md; the durable defence is the test shape used here, asserting the message states the fact exactly once from both sides rather than merely asserting the absence of the wrong word.Verification
test/test_monitor_start_ack.pypasses in full, plus the babysit / autonudge / monitor suites.isort,flake8,mypy --platform linux,scripts/check_black_formatting.py,scripts/check_builtin_skill_scope.py,scripts/docs-lint.sh, andtest/test_security_posture.pyclean.mainand squashed to one commit. The earlier three-dot range that two review lanes read as also containing askills.pysync rework was stale-base noise: that work is main's own fix(skills): sync an installed builtin when only its scripts change #8050, this branch never carried it, and the range is now exactly the two files above (+33/-1).Refs #7634.