fix(babysit): report an owed terminal turn as terminal, not as a spent cap - #8122
Conversation
…t cap
## What is the problem?
A channel-bound babysit loop whose subject reaches a terminal state can report the
wrong ending: the pull request merged, but the user is told the loop ran out of
cycles.
A channel-bound loop deliberately does not settle on observation -- it learns its
watch finished from a delivered turn rather than a dashboard notification. So when
the probe sees a terminal subject it records the OWED turn in
`monitor.terminal_pending` ("success" if merged else "blocked") and leaves the loop
active with no `outcome` and no `monitor_terminal` reason. If that final turn is
refused because the channel is busy -- an ordinary case for a live thread -- the
retry is armed with backoff, and on the retry `_timer` reaches its stop checks
first: sentinel, cycle cap, wall-clock budget, approval stall, each with its own
`return`. A spent cap deactivates the loop with `stopped_reason="cycle_cap"` before
the settlement that would have promoted the debt ever runs.
The notifier then derives `terminal` from `stopped_reason` alone, so the terminal
branch is skipped and the cap wording is announced.
## Why this issue matters to the user
The ending is not merely missing, it is misleading, and the truth is already
durably persisted: a field on disk says the subject terminated while the notice
says the loop ran out of cycles. A watch that SUCCEEDED reports the same signal as
a genuinely stalled one, so the reader cannot tell a finished job from an abandoned
one, and the natural response -- re-arm the watch on a subject that has already
merged -- costs a fresh loop for nothing.
## How our fix solves it
The notifier already expresses "a terminal subject outranks every early-stop
reading" ONCE, as a `terminal` flag the earlier branches defer to, precisely so the
rule does not have to be re-added per branch. Its own comments record two prior
rounds of this same class: the cap guard, then the wall-clock budget, each coming
to preempt the terminal branch in turn. This is the third instance, and the same
one-expression shape absorbs it.
- `terminal` now reads `stopped_reason == monitor_terminal` OR an outstanding
`monitor.terminal_pending`. An owed terminal turn is terminal news too.
- The merged-vs-closed-unmerged distinction takes the settled `outcome` first and
falls back to the debt, which draws the same distinction from the same vocabulary
(`success`/`blocked`, matching `MonitorOutcome`). Without that fallback a merged
subject arriving on the debt alone would be announced as closed-unmerged -- the
defect moved rather than fixed.
Deliberately NOT changed, and this bounds the fix:
- **Ordering in `_timer` is untouched.** No stop check moves, and no turn is
delivered that would not have been delivered before.
- **`max_cycles` semantics are untouched.** The cap still stops the loop exactly
where it stops today; nothing is exempted from it.
- **`stopped_reason` is untouched.** The cap genuinely was spent and that stays
observable, so every consumer of the `cycle_cap` literal behaves as before --
notably `session_directive_apply.py`, where a `cycle_cap` loop is revived by
`monitor_update` when the cap is raised. Overwriting the reason would have
silently removed that affordance.
- **No new field and no schema change.** `terminal_pending` already exists and is
already persisted.
So this changes only what the ending REPORTS. Whether an owed terminal turn should
instead outrank the cap in `_timer` and be DELIVERED is the separate, contested
half of the issue -- two reviewers read the current ordering in opposite directions
-- and it remains an open maintainer decision. That is why this carries `Refs` and
not `Closes`.
## What tests we did
`test/test_slack_gateway_more_coverage.py::TestNotifyNudgeExpired`, three new
cases, each mutation-verified:
- `test_an_owed_terminal_turn_outranks_the_cycle_cap` -- the motivating case.
Reddens when `or bool(owed)` is reverted (announces the cap), and again when the
wording fallback is reverted (announces a merged subject as closed-unmerged).
- `test_an_owed_blocked_turn_is_not_reported_as_a_merge` -- a closed-unmerged
subject must not be told "no action needed". Reddens when `or bool(owed)` is
reverted.
- `test_a_spent_cap_with_no_owed_turn_still_reports_the_cap` -- the control, so the
carve-out cannot swallow a genuine cap. Passes on the pre-fix base, and reddens
when `terminal` is forced True, so it can actually fail.
Both new assertions were confirmed RED against unmodified source before the fix
existed. Full local runs: `test_slack_gateway_more_coverage.py` 55 passed,
`test_slack_gateway.py` 302 passed, `test_slack_gateway_coverage.py` 96 passed,
`test_autonudge.py` 176 passed. `mypy src/kiro_crew/` clean over 1277 files;
black, isort, flake8, `docs_lint.py --test`, `docs-lint.sh` and
`scrub-lint.sh --no-history` all pass.
## Any other suggestions on the work
The deferred terminal-settlement machinery landed in #7634 and `terminal_pending`
appears nowhere in `docs/`. This commit documents the notifier precedence it
changes, in `learn-cron-dashboard.md`, but the debt mechanism itself is still
otherwise undocumented -- worth a dedicated pass by whoever owns that surface.
Refs #8060
Refs #7634
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Wording fixed at the one precedence point built for it, root ordering question honestly deferred to #8060 — bounded, reversible, control-tested. [DESIGN-REVIEWED] 82e1c13 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified. The mechanism exists as described ( First-Principles-Verdict: PASS A reported defect — a merged watch announced as a spent cap — fixed at the one wording chokepoint, with the contested delivery question honestly deferred. What this change shipsIntent: stop telling a user their finished watch ran out of cycles when the finish is already recorded on disk — a FIX.
No new field, config key, flag, or exported symbol; nothing undeclared. Consumer/sibling counts I ran: The deeper cause (an owed terminal turn losing to the cap in [FIRST-PRINCIPLES-REVIEWED] 82e1c13 |
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: |
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: |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 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: a channel-bound babysit loop whose owed terminal turn was refused and whose retry found a spent bound announced the cycle cap instead of the terminal outcome -- the expiry notice now consults the persisted terminal_pending debt, and does so for the wording only so stopped_reason and every consumer of that literal are unchanged. Spec files changed as a ride-along (a minority of the diff on both file count and changed lines), not reviewed as a design decision: docs/system-specs/modules/learn-cron-dashboard.md.
…ent bound (#8201) A channel-bound monitor loop does not settle on observation: the probe records the owed final turn in `monitor.terminal_pending` and leaves the loop active with no `outcome`. If that turn is refused because the channel is busy -- the ordinary case for a live thread -- and the retry finds a bound spent, the loop deactivates tagged with that bound before the settlement that would promote the debt ever runs. The expiry notice already consults the debt for its wording. The `monitor_update` paused-loop branch did not: it read `stopped_reason` alone, so the same loop was reported to the agent as "it hit its cycle cap", and a settled terminal loop as "it was paused manually". A patch that also raised the bound then satisfied the revival condition and injected `active=True`, re-arming a watch on a subject that had already merged -- the wasted fresh loop this branch exists to prevent. That branch now derives the terminal reading the same way the notice does -- `stopped_reason == monitor_terminal` or an outstanding `terminal_pending`, with the merged-vs-closed-unmerged distinction taken from the settled `outcome` falling back to the debt, which speaks the same `success`/`blocked` vocabulary -- and a terminal subject is refused with that news instead of revived. The precedence is expressed once, as a term in the revival decision, rather than as a guard per branch: the notice next door lost this same precedence three times because each newly added bound was evaluated ahead of it. Deliberately unchanged: no stop check moves, `max_cycles` semantics and `stopped_reason` stay untouched so the spent bound remains observable, the revival affordance for a genuine cap or budget is preserved (covered by a control test), and no field or schema is added. Whether an owed terminal turn should instead outrank the cap and be DELIVERED is the separate contested half of the issue and remains an open maintainer decision. Refs #8060 Refs #8122 Co-authored-by: LuisBrel <193017400+LuisBrel@users.noreply.github.com>
What is the problem?
A channel-bound babysit loop whose subject reaches a terminal state can report the
wrong ending: the pull request merged, but the user is told the loop ran out of
cycles.
A channel-bound loop deliberately does not settle on observation -- it learns its
watch finished from a delivered turn rather than a dashboard notification. So when
the probe sees a terminal subject it records the OWED turn in
monitor.terminal_pending("success" if merged else "blocked") and leaves the loopactive with no
outcomeand nomonitor_terminalreason. If that final turn isrefused because the channel is busy -- an ordinary case for a live thread -- the
retry is armed with backoff, and on the retry
_timerreaches its stop checksfirst: sentinel, cycle cap, wall-clock budget, approval stall, each with its own
return. A spent cap deactivates the loop withstopped_reason="cycle_cap"beforethe settlement that would have promoted the debt ever runs.
The notifier then derives
terminalfromstopped_reasonalone, so the terminalbranch is skipped and the cap wording is announced.
Why this issue matters to the user
The ending is not merely missing, it is misleading, and the truth is already
durably persisted: a field on disk says the subject terminated while the notice
says the loop ran out of cycles. A watch that SUCCEEDED reports the same signal as
a genuinely stalled one, so the reader cannot tell a finished job from an abandoned
one, and the natural response -- re-arm the watch on a subject that has already
merged -- costs a fresh loop for nothing.
How our fix solves it
The notifier already expresses "a terminal subject outranks every early-stop
reading" ONCE, as a
terminalflag the earlier branches defer to, precisely so therule does not have to be re-added per branch. Its own comments record two prior
rounds of this same class: the cap guard, then the wall-clock budget, each coming
to preempt the terminal branch in turn. This is the third instance, and the same
one-expression shape absorbs it.
terminalnow readsstopped_reason == monitor_terminalOR an outstandingmonitor.terminal_pending. An owed terminal turn is terminal news too.outcomefirst andfalls back to the debt, which draws the same distinction from the same vocabulary
(
success/blocked, matchingMonitorOutcome). Without that fallback a mergedsubject arriving on the debt alone would be announced as closed-unmerged -- the
defect moved rather than fixed.
Deliberately NOT changed, and this bounds the fix:
_timeris untouched. No stop check moves, and no turn isdelivered that would not have been delivered before.
max_cyclessemantics are untouched. The cap still stops the loop exactlywhere it stops today; nothing is exempted from it.
stopped_reasonis untouched. The cap genuinely was spent and that staysobservable, so every consumer of the
cycle_capliteral behaves as before --notably
session_directive_apply.py, where acycle_caploop is revived bymonitor_updatewhen the cap is raised. Overwriting the reason would havesilently removed that affordance.
terminal_pendingalready exists and isalready persisted.
So this changes only what the ending REPORTS. Whether an owed terminal turn should
instead outrank the cap in
_timerand be DELIVERED is the separate, contestedhalf of the issue -- two reviewers read the current ordering in opposite directions
-- and it remains an open maintainer decision. That is why this carries
Refsandnot
Closes.What tests we did
test/test_slack_gateway_more_coverage.py::TestNotifyNudgeExpired, three newcases, each mutation-verified:
test_an_owed_terminal_turn_outranks_the_cycle_cap-- the motivating case.Reddens when
or bool(owed)is reverted (announces the cap), and again when thewording fallback is reverted (announces a merged subject as closed-unmerged).
test_an_owed_blocked_turn_is_not_reported_as_a_merge-- a closed-unmergedsubject must not be told "no action needed". Reddens when
or bool(owed)isreverted.
test_a_spent_cap_with_no_owed_turn_still_reports_the_cap-- the control, so thecarve-out cannot swallow a genuine cap. Passes on the pre-fix base, and reddens
when
terminalis forced True, so it can actually fail.Both new assertions were confirmed RED against unmodified source before the fix
existed. Full local runs:
test_slack_gateway_more_coverage.py55 passed,test_slack_gateway.py302 passed,test_slack_gateway_coverage.py96 passed,test_autonudge.py176 passed.mypy src/kiro_crew/clean over 1277 files;black, isort, flake8,
docs_lint.py --test,docs-lint.shandscrub-lint.sh --no-historyall pass.Any other suggestions on the work
The deferred terminal-settlement machinery landed in #7634 and
terminal_pendingappears nowhere in
docs/. This commit documents the notifier precedence itchanges, in
learn-cron-dashboard.md, but the debt mechanism itself is stillotherwise undocumented -- worth a dedicated pass by whoever owns that surface.
Pattern harvest
Rule candidate: manual review heuristic (weak as a syntactic pattern; the tell is
semantic).
Pattern: a precedence rule that is correctly expressed ONCE, but whose input is a
field only written by a path an earlier
returncan skip. Here the notifier says "aterminal subject outranks every early-stop reading" and reads that from
stopped_reason == monitor_terminal-- a value written by the SETTLEMENT. For achannel-bound loop the settlement is deliberately deferred, so when a stop check
returns first the promoted field is never written and the precedence silently
inverts. The underlying truth was already recorded, earlier and durably, in a
different field (
monitor.terminal_pending).The tell is a decision that reads a PROMOTED field while the same fact is available
in a DURABLE pending/owed field written unconditionally further upstream. Whenever
those two exist, the decision must consult the upstream one as well, or it reports
whatever the preempting path happened to write.
Why this class recurs rather than being a one-off: the same precedence had already
been lost twice in this same function -- first to the cycle-cap guard, then to the
wall-clock budget -- and each round was fixed by removing a guard from the terminal
branch. That treats the symptom. Consolidating the rule into one
terminalflag(the state this PR found) removed the per-branch guards but left the flag reading a
field that can go unwritten, so instance three arrived through the input rather than
through the branches. A precedence rule is only as reliable as the weakest writer of
the field it reads.
Counter-check that belongs with the rule: fixing such a decision by REWRITING the
preempting path's recorded value is the tempting move and is usually wrong. Here it
would have silently removed the
monitor_updaterevival affordance keyed on thecycle_capliteral, and replaced a misleading "ran out of cycles" with amisleading "paused manually". Read the second field; do not overwrite the first.
Refs #8060
Refs #7634