fix(autonudge): create-only monitor arms no longer deadlock on a stopped automation - #8515
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound deadlock fix with a fail-closed evidence split, but the description claims a Watch
Suggestions
[DESIGN-REVIEWED] f6ce681 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/autonudge.py:174 -- False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified: the remedy message that creates the deadlock is real ( First-Principles-Verdict: CONCERNS The description claims the monitor_stop → monitor_watch deadlock is fixed red/green; the diff pins the opposite — USER_STOP rows still refuse the re-arm. What this change shipsIntent: let a session re-arm its own automation after the system stopped it, which the retained stop record previously deadlocked — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] f6ce681 |
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: |
7e0e3ed to
14bfb5d
Compare
Disposition — GPT blocking finding (round 1)
|
14bfb5d to
aef7698
Compare
Disposition — GPT blocking finding (round 2)
|
aef7698 to
9968592
Compare
Disposition — GPT blocking finding (round 3, resolved under owner ruling)
|
9968592 to
2e9c6e4
Compare
Disposition — GPT round 4 (blocking)
|
2e9c6e4 to
1ef558d
Compare
Disposition — GPT round 4 (advisory, tool description)
|
Disposition — GPT advisory (stale rationale comment)
|
monitor_start and monitor_watch became create-only so a racing arm cannot clobber a live automation, but the occupancy test read ANY retained row as an occupant. A loop stopped for approval_stalled is retained inactive on purpose, and monitor_update refuses to revive it while naming monitor_start as the remedy -- which then refused too, deadlocking the session's only re-arm path (observed live on a babysit re-arm). Under the owner's ruling, the directive re-arm path gains a replace_stopped opt-in that displaces only system-imposed stops (an approval stall, a spent cap or budget, a finished subject); consumer- recorded stops -- manual pauses, user stops, session-close retention, research tombstones, quarantined records -- are preserved as evidence, with unknown reasons failing closed to preserved. Dashboard REST creates keep any-record occupancy, and a future-version record is never replaceable.
1ef558d to
f6ce681
Compare
Disposition — GPT advisory (authorizer docstring)
|
Disposition — Opus advisory (TARGET_UNAVAILABLE re-armability)
|
Disposition — GPT advisory (USER_STOP re-armability)
|
buluoray
left a comment
There was a problem hiding this comment.
Reviewed against source at f6ce681, with the guards mutation-tested. 0 blocking findings.
One correction to the framing: this is not an async lock deadlock — there is no second acquisition and no await-under-lock cycle. It is a product-flow deadlock, and it reproduces from pre-fix source. session_directive_apply.py:488 tells the user to "re-enable auto-approve, then re-arm it with monitor_start", but monitor_start applied with replace_existing=False, and the pre-fix guard refused over any retained row (autonudge.py:1301,1397) — including the inactive approval-stall row that triggered the advice. The documented remedy had no working path. Circular, and real.
The fix is correctly scoped. replace_stopped narrows the create-only refusal to active rows, and it is set True at exactly two sites, both session-directive appliers (session_directive_apply.py:262,332), defaulting False everywhere else — so dashboard REST creates still 409 at any record. Both occupancy guards were fixed (autonudge.py:1376,1518), with no unfixed sibling.
The narrowed-guard race does not apply here, which was my main concern going in. In both _add_unserialized and _add_monitor_locked, _find_by_slot → decision → remove_sync → insert → persist all stay inside the same single async with self._lock hold; the lock scope is byte-for-byte unchanged and only the branch predicate moved, so the read-then-write stays atomic. Every new raise is inside the context manager, and the legacy path's except BaseException still restores the prior loop on a persist failure. No asyncio primitive is created, so the Loop-Bound Locks Gate is satisfied on substance, not just on the green check.
Mutation results:
| Mutation | Result |
|---|---|
Revert to if not replace_existing: (reintroduce the deadlock) |
3 RED, with the live session already has an automation error |
_stopped_row_is_replaceable → always True (defeat the evidence allowlist) |
4 RED (user-stop, research tombstone, manual pause, quarantine) |
| Disable the future-version guard | 1 RED |
Unknown stop reasons fail closed to preserved, the MONITOR_STOP_INVALID_RECORD quarantine is excluded, future-version retention is refused first, and learn-cron-dashboard.md plus the monitor_start tool description are synced in the same commit.
Non-blocking, and the first one is worth fixing before merge:
- The PR description does not describe this diff.
## Summaryand## Testedclaim amonitor_stop → monitor_watchfix and citetest_create_only_add_monitor_replaces_a_terminal_monitor, which is not in the diff. The shipped code does the opposite on purpose:test_replace_stopped_preserves_a_user_stopped_monitorpins that aUSER_STOPre-arm is still refused. The behavior is right —USER_STOPis consumer-recorded evidence, and the dashboard restart route (replace_existing=True, conditional on monitor id + config generation) is the successor path, so no residual deadlock — but the prose should match. - Cap re-arm is a real capability expansion:
cycle_cap/runtime_budget/BUDGETare re-armable, so an automation's own final wake can reset its own bound, making caps per-incarnation rather than per-session. Each arm still passesauthorize_and_add_nudgeadmission and it is disclosed in the tool description — flagging for awareness, not as an objection. - The version / evidence-allowlist / wake-in-flight refusal triplet is copy-pasted across both add paths with shared exception text; a shared helper would prevent drift.
Not verified: the full 282-test suite across the four cited files, the mypy/black/docs-lint gates, and the auto_research watchdog's consumption of retained stop-reason rows (I relied on the diff's three read sites, which are consistent with the fail-closed preservation this adds).
Summary
monitor_startandmonitor_watchare create-only so a racing arm cannot clobber a live automation — but the occupancy test read ANY retained row on the slot as an occupant, including inactive ones. That deadlocks the product's own documented remedy:stopped_reason="approval_stalled"(deliberate: inspectable/restartable).monitor_updaterefuses to revive an approval-stalled loop and says "re-enable auto-approve, then re-arm it with monitor_start".monitor_startthen refuses too —MonitorUpdateConflict: session already has an automation— because the retained inactive row still occupies the slot.Observed live: a babysit re-arm at 2026-09-04 05:33:48 UTC bounced off its own predecessor's approval-stall tombstone (
session-directive DENIED at apply for session_key='dashboard:chat-105-…' kind='monitor_start'), with the same denial on two other dashboard slots in the same gateway log. The structured twin exists too:monitor_stopretains a terminal record for inspection, after whichmonitor_watchin the same session is refused forever.Fix
A deliberate split, per GPT's round-1 security finding (its first cut widened the shared predicate and would have let dashboard REST creates silently delete retained inspection records):
replace_stoppedopt-in (defaultFalse), threaded throughauthorize_and_add_nudge.monitor_start,monitor_watch) set it: their create-only refusal narrows to ACTIVE records, so a retained inactive row — approval-stalled, capped, budget-spent, or a terminal monitor kept for inspection — is replaced atomically inside the same locked write. This is the deadlock fix, scoped to the exact path the remedy message points at.replace_existing=Falsealone still refuses over any retained row, active or stopped, so inspection history is never discarded by a create — pinned byte-identically by two new tests.The
monitor_starttool description anddocs/system-specs/modules/learn-cron-dashboard.mdare synced in the same commit.Tested
Red-first, proven by stashing the production fix:
test_create_only_add_replaces_an_inactive_approval_stalled_loop— red on unfixed code with the exact live error, green after.test_create_only_add_monitor_replaces_a_terminal_monitor— themonitor_stop→monitor_watchdeadlock, red before / green after.test_create_only_add_still_refuses_a_terminal_row_with_an_inflight_wake— pins the transition from the blanket refusal to the wake-in-flight refusal.test_create_only_add_still_refuses_an_active_legacy_loop— a LIVE automation refuses even on thereplace_stoppedpath.test_dashboard_create_only_add_preserves_a_stopped_row/test_dashboard_create_only_add_monitor_preserves_a_terminal_record— the GPT-finding pins: dashboard-style creates (no opt-in) refuse over inactive rows and leave the store byte-identical.Focused suites:
test_autonudge.py+test_autonudge_stop_auth.py+test_monitor_mcp.py+test_driver_session_directives.py— 282 passed. One deselected failure,TestSentinelPathRepair::test_unnormalized_path_escaping_legacy_is_preserved, reproduces identically on pristineorigin/mainon this host (a/local/homerealpath artifact) and is unrelated.Gates: black ratchet, subprocess-encoding ratchet, isort, flake8, mypy (CI-parity venv, 1,287 files), docs-lint — all clean on the committed head.
Pattern harvest
Rule candidate: an occupancy/conflict guard keyed on record EXISTENCE where the protected invariant is record LIVENESS (or vice versa) — whenever a
refuse-if-presentcheck is added over a store that deliberately RETAINS terminal rows, require a test that arms-after-stop, or the retention feature and the guard deadlock each other. Reviewable mechanically: any newraiseon_find_by_*truthiness deserves anactive/terminal-state audit. The second half of this defect (a shared chokepoint fix silently widening sibling callers' semantics) is the existing enumerate-the-callers discipline and needs no new rule.