Skip to content

fix(babysit): report an owed terminal turn as terminal, not as a spent cap - #8122

Merged
iamwhatever merged 1 commit into
mainfrom
fix/babysit-terminal-vs-cycle-cap-8060
Sep 3, 2026
Merged

fix(babysit): report an owed terminal turn as terminal, not as a spent cap#8122
iamwhatever merged 1 commit into
mainfrom
fix/babysit-terminal-vs-cycle-cap-8060

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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.

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 return can skip. Here the notifier says "a
terminal subject outranks every early-stop reading" and reads that from
stopped_reason == monitor_terminal -- a value written by the SETTLEMENT. For a
channel-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 terminal flag
(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_update revival affordance keyed on the
cycle_cap literal, and replaced a misleading "ran out of cycles" with a
misleading "paused manually". Read the second field; do not overwrite the first.

Refs #8060
Refs #7634

…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
@chenmingwei23
chenmingwei23 requested a review from a team as a code owner September 3, 2026 08:06
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 82e1c1373a077968b3759339d95ff0872291659c — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 82e1c1373a077968b3759339d95ff0872291659c — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All claims verified. The mechanism exists as described (terminal_pending written by the probe at autonudge.py:2379, settled only after a delivered turn at autonudge.py:2857-2937), the notifier is the only site deriving terminal wording from stopped_reason, and no new surface is added. Final review:

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 ships

Intent: stop telling a user their finished watch ran out of cycles when the finish is already recorded on disk — a FIX.

  1. A capped-out loop owing a terminal turn is now announced as finished/stopped, not as a spent cap — justified (reported defect, truth persisted in terminal_pending).
  2. Merged vs closed-unmerged wording falls back to the owed debt when no settled outcome exists — justified (without it the same defect reappears as "closed unmerged" on a merged subject).
  3. Spec paragraph in learn-cron-dashboard.md documenting the precedence — justified (AGENTS.md same-commit spec rule; derived).

No new field, config key, flag, or exported symbol; nothing undeclared. Consumer/sibling counts I ran: stopped_reason == MONITOR_TERMINAL_REASON as a wording input exists at exactly 1 site (gateway.py:6004, the one fixed); terminal_pending has 0 readers under website/src and no other user-facing reader, so no unfixed siblings. No existing "effective terminal" predicate duplicates this — the two reads are the first combination of these fields.

The deeper cause (an owed terminal turn losing to the cap in _timer's stop order) is nameable but genuinely contested and out of scope; the description states the level this fix sits at and tracks the remainder (issue #8060, Refs not Closes), which is exactly what the depth lens requires of a mechanism-level fix.

[FIRST-PRINCIPLES-REVIEWED] 82e1c13

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 82e1c1373a077968b3759339d95ff0872291659c and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 82e1c13

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 82e1c1373a077968b3759339d95ff0872291659c: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 82e1c1373a077968b3759339d95ff0872291659c — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 82e1c13

Verdict parsed from the review's SHA-scoped output markers for commit 82e1c1373a077968b3759339d95ff0872291659c.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 82e1c1373a077968b3759339d95ff0872291659c: <one-sentence reason>

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 3, 2026
@iamwhatever
iamwhatever enabled auto-merge (squash) September 3, 2026 09:10

@iamwhatever iamwhatever left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@iamwhatever
iamwhatever merged commit 16e6ea0 into main Sep 3, 2026
71 of 73 checks passed
@iamwhatever
iamwhatever deleted the fix/babysit-terminal-vs-cycle-cap-8060 branch September 3, 2026 09:11
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 3, 2026
iamwhatever pushed a commit that referenced this pull request Sep 3, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants