feat(dashboard): trigger a goal loop's next nudge from the popover - #8993
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Reusing the scheduled Suggestions
[DESIGN-REVIEWED] 05bf62d |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- website/src/components/AutoNudgePopover.tsx:238 -- direct False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All the claimed existing mechanisms check out: the two-term busy predicate has 20+ consumers, the queue-refusal rationale exists verbatim at First-Principles-Verdict: PASS Every item traces to issue #8212, a named usability report, or a documented rule; the one deep decision — deleting the deadline write — removes a race class instead of guarding it. What this change shipsIntent: let an operator who already knows the watched thing changed run a goal loop's next cycle now instead of waiting out the idle gap — an ADDITION.
Watch
[FIRST-PRINCIPLES-REVIEWED] 05bf62d |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of All evidence is in hand — blind read, five screenshots, the diff, and the PR body (no recordings, but the popover closes on stop/save so the active↔paused flip never happens on screen). Reconciliation: the primary "Trigger nudge" button was read correctly and the reader would use it; the residual findings are vocabulary drift and the post-press disabled state, all grounded in the reader's words. UX-Verdict: CONCERNS The button works and reads correctly, but three nouns for one lifecycle — nudge/cycle, stop/paused/start — leave a cold reader guessing at every state change. Watch
[UX-REVIEWED] 05bf62d |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI have enough to validate. Let me work through both candidates against the code I've now opened. Candidate 1 — button Tracing Candidate 2 — In the success case this is the intended acknowledgment the PR designs for (the disabled state is deliberate; screenshot frame 4 pins it). The alleged "stuck" case requires a turn to start in the tiny window between the Neither candidate survives falsification, and I found no separate grounded defect in the handler ( No findings. [OPUS-REVIEWED] 05bf62d Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
c9f2ef5 to
ccebda8
Compare
ccebda8 to
cdd5a59
Compare
cdd5a59 to
eb28a9a
Compare
eb28a9a to
06ab896
Compare
165b3d2 to
e9eb700
Compare
e9eb700 to
1764b91
Compare
1764b91 to
191c291
Compare
191c291 to
f504b32
Compare
f504b32 to
a2e1e29
Compare
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: |
The goal loop's interval is an IDLE gap, so an operator who already knows the
thing being waited on has changed had no way to tell the loop to look now: they
either sat out the remaining gap, or pasted the check instruction into the
composer by hand, which duplicates a prompt the loop already holds and does not
reset the timer, so the scheduled nudge fires right afterwards and the agent
runs the same check twice.
Adds a "Trigger nudge" button to the goal loop popover and one route behind it,
POST /api/autonudge/{loop_id}/fire.
The mechanism reuses the scheduled path rather than adding a second one.
fire_now moves the loop's deadline to now and re-arms through _arm_timer, so the
cycle runs inside the ordinary _timer body: the stop sentinel, the cycle cap,
the wall-clock budget, the approval-stall stop and the probe gate all still
apply, and delivery goes through the one _on_fire path. The countdown reset the
issue asks for is inherited rather than implemented -- a delivered cycle already
clears next_due_ts and the re-arm then starts a fresh full interval, so the next
automatic nudge lands one idle_secs after the manual turn ENDS.
Both questions the issue deferred are answered from the tree, not chosen here:
a manual cycle counts against max_cycles because cycle_count is documented as
counting DELIVERED turns, and a busy session is refused rather than queued
because the fire path already recorded that decision with its reason (queueing
"would stack identical 3KB+ nudges and blow up the context window").
Refs #8212
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review: approved.
Authorization scoping — the new POST /api/autonudge/{loop_id}/fire sits on the same dashboard-token-gated path as its existing POST/PATCH/DELETE siblings and introduces no new trust boundary. Cross-user triggering is not representable here for the same reason it is not for the siblings: autonudge_authz documents that ownership is not checked by loop id ("the REST route is user-token gated for the dashboard UI"), and identity-bearing callers resolve the id from their own binding key. The route is strictly less powerful than the POST beside it, which arms a loop that spends turns until a bound stops it, and it cannot buy a turn past a bound: fire_now re-arms through _arm_timer so the stop sentinel, cycle cap, wall-clock budget, approval-stall stop and probe gate all still apply, and not loop.active covers every non-removing terminal bound in one condition.
Scope — proportionate. Of +1759/-12 across 27 files, production code is autonudge.py +89 (mostly docstring), handlers/autonudge.py +200 (mostly docstring), server.py +2 route registration, and AutoNudgePopover.tsx +144/-7; the rest is 744 lines of new backend tests, a 353-line capture script, 197 test lines, and two-line locale additions. No unrelated surface touched.
Audit — the fire is audit-or-deny on a critical=True write awaited off-loop before anything is armed (503 audit_unavailable, nothing armed), refusals emit best-effort denied events, and every exit routes through one of the two helpers so a later guard cannot silently skip the record.
Unresolved blocking findings — none. All 66 check-runs are success/skipped with zero failures on 05bf62d, all five reviewer lanes are green with 0 annotations, and no human review requested changes. The residual notes are advisory: Design (PASS) would prefer an authorize_and_fire_nudge chokepoint in autonudge_authz for a future non-HTTP caller — a reasonable follow-up, not a defect for the HTTP surface this PR ships; UX (CONCERNS) flags nudge/cycle vocabulary drift; GPT's non-blocking note on the raw fetch is mitigated in the diff by the AUTONUDGE_LOOPS_QUERY_KEY invalidation, and is style debt worth a follow-up rather than a block.
i18n — compliant. Two keys (trigger_nudge, loop_paused) added to en.manual.json plus all eleven translated catalogues with en-XA regenerated; en.json is generated and correctly not hand-edited. The paused-state and Start loop labels reuse existing keys, so no catalogue gains an unverifiable hand-authored sentence. The diff-scoped zero-tolerance i18n checks pass.
Merging is still gated on the kirocrew-ux-reviewers team approval required by the UX ruleset, since website/src/components/AutoNudgePopover.tsx is a non-test, non-locale website/src/** file.
Problem / Motivation
A goal loop's interval is an idle gap, not a poll. When the operator already
knows the thing being waited on has changed -- CI finished, a review landed, a
deploy went out -- there was no way to tell the loop to look now. The autonudge
HTTP surface was list / get / start / update / delete, and the MCP surface has
only
autonudge_stop, so nothing anywhere could bring a cycle forward.The two workarounds both cost something. Sitting out the remaining gap wastes
it. Pasting the check instruction into the composer by hand duplicates a prompt
the loop already holds and does not reset the timer, so the scheduled nudge
fires right afterwards and the agent runs the same check twice.
Why it matters
The loop becomes responsive to what the operator knows, instead of only to the
clock. Concretely: on a 300s interval, learning that a PR just went green no
longer means either a five-minute wait or a duplicated turn.
What changed (motivation -> approach -> change)
One button in the goal loop popover, and one route behind it:
POST /api/autonudge/{loop_id}/fire.The mechanism reuses the scheduled path rather than adding a second one.
AutoNudgeService.fire_nowmoves the loop's deadline to now and re-arms through_arm_timer, so the cycle runs inside the ordinary_timerbody. Calling_run_fire_cycledirectly would have needed the whole terminal ladder restatedat a second site, and a second copy of a five-condition gate is a divergence
waiting to happen. So the stop sentinel, the cycle cap, the wall-clock budget,
the approval-stall stop and the probe gate all still apply, and delivery goes
through the one
_on_firepath.What it does to the schedule is nothing that is not already true of a
scheduled fire, which is the property worth stating because a manual trigger
that silently shifted the interval would be a defect wearing a feature's
clothes. A delivered cycle already clears
next_due_ts, and the re-arm thenstarts a fresh full interval -- for a dashboard slot via
notify_turn_complete->
_arm_from_deadline. So the countdown reset the issue asks for isinherited, not implemented: the next automatic nudge lands one full
idle_secsafter the manual turn ends. Deadline preservation is untouched --notify_user_inputstill cancels only the task and leavesnext_due_tsalone,so a user turn defers this cycle rather than cancelling it.
delay=0.0rather than_arm_from_deadline's 10s_OVERDUE_REARM_SECSbeat.That beat exists so an elapsed deadline does not ambush a user mid-conversation;
a manual trigger is the user asking, so the condition it protects against is
absent.
next_due_tsis still written to now, so a gateway restart between thepress and the fire resumes with an overdue deadline and takes the beat --
correct there, because by then nobody is pressing anything.
What the usability round changed
A blind reader of the screenshots could not tell the paused frame was the same
loop, so the schedule line now SAYS
Pausedwhere the button would be -- thestate is the reason for the absence, so it occupies the space the absence
leaves. The refusal reads "the agent is still working" rather than "mid-turn",
which was this codebase's vocabulary and not a user's. And a FOURTH frame now
photographs the successful press: the PR claimed the schedule line flips to
"due" in place and nothing showed it, which was an unbacked claim in my own
description. The
Pausedstring is one new key, and no value was coined -- eachlocale's is its own existing word for paused, taken from its own translation of
state_paused.Declined: a
titlenaming the cycle cost. It is UX's named smallest fix and myearlier reason (the header readout advances) was fairly rebutted as post-hoc --
but a
titleis unreachable by touch and keyboard, and I will not hand-authoreleven translations of a new sentence I cannot verify. A maintainer who wants it
should have it as visible text, not a tooltip.
The new deadline is durable before the press is acknowledged
fire_nowis async andawaits its persist, and on a write failure it putsthe previous deadline back and arms NOTHING (503
autonudge_not_persisted).The rest of this class uses the detached
_persist_soon, but that helper scopesitself in its own docstring to "sync callers ... that cannot await
_persist_lockedthemselves" and accepts that "a lost write degrades to a freshfull countdown after restart". That trade is right for the turn-lifecycle hooks,
which assign a deadline nobody asked for and can re-derive it. It is wrong for a
button: a crash in the window before the detached write flushed reloads the OLD
future deadline, so the press reported
200and the cycle it promised neverhappens. It is also wrong specifically because this route now writes a
critical=Trueaudit saying the fire was invoked -- leaving the deadlinebest-effort would let the durable record and the durable state disagree about the
same press. Awaiting is this module's own pattern for mutations entering from
async code; the staged-monitor paths all await theirs.
The deadline write is gone, and that is the fix
fire_nowhas NO suspension point:async deffor the caller's convenience,but nothing inside awaits, so the guards and the arm are atomic with respect to
the event loop.
_arm_timer(delay=0.0)is what brings the cycle forward --_timersleeps the delay it is given and fires WITHOUT consultingnext_due_ts.This shape was reached the hard way, and the history belongs in the record
because it is the argument for the shape. An earlier revision wrote
next_due_ts = time.time()and awaited a durable persist before arming, so arestart between the press and the fire would resume overdue. That await was a
suspension window, and this module has several writers to
next_due_tsthathold no lock at all while writing it -- the quiet-tick re-arm on the
gated-wake branch (
autonudge.py:3979) is one. Five successive review roundseach found the next window, and each was real: a concurrent
removearming astale object, a cancelled caller abandoning the write, a countdown entering
_firingmid-persist, a quiet tick overwriting the deadline, then the refusedpath leaving its own value durably committed. Every fix was correct and every
one created the next. The window is not closable at this call site, because the
racing writers take no lock to share.
So the write is gone rather than guarded a sixth time. What is given up is
exactly the restart cosmetics it bought: a crash between the press and the fire
resumes the loop on its own schedule instead of overdue, and the operator
presses again. That is the same degradation
_persist_soondocuments asacceptable for every other deadline assignment in this class -- "a lost write
degrades to a fresh full countdown after restart, never a premature or dropped
fire" -- and eleven other callers already accept it.
Two consequences of removing the write, both taken. The route now returns the
loop UNCHANGED, so rendering the response verbatim would leave the countdown
showing the very cycle the press superseded -- the one visible confirmation a
press has. The component supplies the armed deadline instead, which is not a
fiction (the cycle IS armed to run now) and is reconciled by the delivery's
autonudge_stateframe. The screenshot fixture was corrected to match: it usedto hand the frame a pre-moved deadline, which would have photographed something
the real route cannot produce. It now returns an unchanged loop, so the frame
proves the CLIENT produces the "due" state -- 20/20 with the change, 19/20
without it.
The tests' service directory is now SESSION-scoped rather than per-test.
_persist_soondispatches throughrun_in_executor, and a thread alreadyinside
_write_statecannot be stopped -- cancelling the awaiting task does notreach it -- so a late write could land after a per-test directory was torn down
and recreate it. An earlier revision tried draining those writers at teardown;
that narrows the window but cannot close the cancel branch, and the review that
pressed on it was right.
The countdown reset issue #8212 asks for is unaffected, because it never came
from this write: a delivered cycle clears
next_due_tsin_run_fire_cycleandthe re-arm starts a fresh full interval. The audit-or-deny gate is unaffected
too -- a press still spends a model turn, so it is still recorded before the
fire.
The invariant is asserted mechanically rather than described. A coroutine with
no
awaitcompletes on its firstsend, so the test drives the raw coroutineand fails if it ever yields --
await svc.fire_now(...)would pass either wayand so cannot distinguish them. Reintroducing an
awaitfails that test with amessage naming the five races it reopens. Net effect on this PR: eight tests
and roughly 230 lines of guard code deleted, not added.
Where the button sits, and why not in the action row
On the schedule line, beside the countdown it acts on, not in the Stop/Save
row below.
website/AUTOSDE.yaml:230(max-two-buttons-per-row,blocking: true) holds a row to two controls and names this exact escape in its own text:the third action "goes into an overflow
DropdownMenu... or leaves therow". Leaving is cheaper than a menu for one action, and it is better on the
merits anyway: this control changes the countdown printed beside it, while Stop
and Save act on the loop's configuration. It is a one-button group, so the cap
is satisfied structurally rather than by happening to be under it today.
A successful press does NOT close the popover
Unlike Save and Stop. The route deliberately sends no body, so the nudge fired
is whatever the loop currently holds -- which means a user who edited the
textarea and pressed this gets the armed message. Closing on top of that would
also drop the unsaved edit, with no dirty guard (drafts are not persisted while
a loop exists), so a press after an edit would cost the user their text as well
as spending a turn on the old prompt. Staying open keeps the edit, keeps Save
reachable, and makes the outcome visible in place: the schedule line flips to
"due", and the header's cycle readout advances a moment later when the delivered
fire broadcasts
autonudge_state-- which is also where the press's costagainst the cycle cap becomes observable.
The two questions the issue deferred are answered from the tree, not chosen here
Does a manual trigger count against
max_cycles? Yes._timerdocumentscycle_countas counting delivered turns -- "a floor delivery, a fallbackand a follow-up are all delivered turns that advance it". A manual nudge
delivers one and spends a model turn exactly as a scheduled one does. Reusing
_run_fire_cyclemakes it count with no manual-vs-scheduled branch to getwrong, and not counting it would let a press drive unbounded turns past a cap
that exists to bound cost. The issue itself says either behaviour is workable.
Turn in flight: refused, not queued. The fire path already made that call,
with its reason written at the site: queueing "would stack identical 3KB+ nudges
and blow up the context window" (
slack/gateway.py:6134). The route surfacesthat as a 409
session_busyusing the repository's canonical predicate,slot.running or slot._in_stage_execution, read exactly as the cron-injectionhandler reads it --
slot.runningalone is False between the stages of amulti-stage plan. The two consumers of that predicate diverge deliberately: the
cron path queues, this one refuses.
Deviation from the issue's own wording, stated before anyone asks. The issue
weighed "queue the nudge, or disable until the turn ends". This ships a third
form: the button stays enabled and the press is refused with a visible reason.
Refusing is the recorded decision above; pre-disabling would additionally need
a busy signal threaded from
ChatInputinto the popover, a file ~26 open PRstouch, for an affordance the 409 already communicates. If a maintainer wants the
disabled-button form instead, say so and it is a small follow-up -- which is why
this carries
Refsand not a closing trailer.Refusals, each load-bearing rather than defensive
_arm_timercancels the existing timer, and inside the fire window that task may be parked on_persist_locked()writing the delivered cycle -- the same windownotify_turn_completedefers around, and the same 409 the siblingPOST /api/crons/{id}/rungives for a run already in flight_on_monitor_tickand owned by the monitor API; same codePATCHusesEvery outcome is audited, and the FIRE is audit-or-deny. Two review rounds
landed on this and the split is now the one the repository already draws:
critical=Truewrite that lands BEFOREfire_nowarms anything, awaited through
asyncio.to_thread. This matters because thedefault SEL path only ENQUEUES, and on the event loop an enqueue failure drops
the event with a warning -- so a pre-audit that is not
criticalis a hope,not a gate, and a model turn could run with no record that anything asked for
it. The write goes off-loop because a synchronous flush on the loop would
freeze every session's turn. An unwritable sink returns 503
audit_unavailablewith nothing armed. This is not a new invention: it isthe shape this subsystem's own
autonudge_authzalready uses formonitor_updateandmonitor_stop, and the 503 mirrorshandlers/cron.py's existingaudit_unavailablerefusal for a grant it couldnot record.
deniedevent. The first revision auditedonly after
fire_nowreturned, so the four guards denied requests and left noevent at all -- a real hole. Making them
criticalwould trade an audit-sinkproblem for a different failure while preventing nothing, since the request is
refused either way; that is the disposition
messaging/identitystates for adeny.
timer is armed and the
invokedrecord has landed, so raising would replace areal result with a logging error.
Authentication is the same as the
POST/PATCH/DELETEsiblings on thispath: no new trust boundary, and this route is strictly less powerful than
the
POSTbeside it, which arms a loop that can spend turns until a bound stopsit. Audited like
DELETE, on both outcomes, because a delivered cycle spends amodel turn. Both refusal arms carry a literal status beside their code rather
than a computed one, because the error-code contract caps dynamic statuses --
computing one is how that ratchet gets defeated while looking like ordinary
refactoring.
Documentation surfaces -- both of them
The route is added to its owning spec,
docs/system-specs/modules/learn-cron-dashboard.md, whose AutoNudge sectionenumerates this HTTP surface (AGENTS.md:133 requires the owning spec in the SAME
commit, and the first revision did not do it while its checklist claimed
otherwise -- that was wrong and is fixed). It is also added to the Monitor
loops row of
docs/feature-map/README.md, which is a second, independentenumeration I missed on the first pass for the same reason I missed the first
one: I fixed the doc surface I knew about and assumed it was the doc surface.
The local gate cannot catch this, and that is worth stating rather than
hiding.
scripts/check_feature_map.pyis opt-in (it no-ops withoutFEATURE_MAP_BASE_REF), and even in diff-scoped mode it passes here, because itasserts that a touched file appears somewhere in the map -- and
handlers/autonudge.pyalready did. Route-level completeness is enforced onlyby the AUTOSDE
feature-map-correctnessrule, so this class of miss isreview-caught by design, not gate-caught. Verified by running the gate both
before and after the fix: rc=0 either way.
Collision note: #5186, #8919 and #8936 all edit the spec paragraph, so
whichever lands second inherits a conflict there; the insertion is kept to one
clause plus its rationale to make that resolution mechanical.
i18n
One i18n key,
components.autoNudgePopover.trigger_nudge, inen.manual.jsonplus all eleven translated catalogues (
en.jsonis generated and was nothand-edited;
en-XAregenerated). No value was coined: each is that locale'sown imperative button form (from
stop_loop/start_loopin the sameobject) applied to that locale's own word for "nudge" (from
seconds_between_nudgesin the same object).Tests
New:
test/test_autonudge_manual_fire.py(20 tests) and 10 added towebsite/src/test/AutoNudgePopover.test.tsx(41 in that file now).Both properties that matter more than the button are asserted rather than
described. The schedule claim has its own test
(
test_the_next_automatic_nudge_is_a_full_interval_after_the_manual_turn_ends),and the bounds get one test per gate -- the cap and the sentinel separately,
because a single test is only proven to the first gate that fires.
Mutations, each hand-applied with its anchor asserted unique first, each
reddening a distinct set with a distinct message:
KeyError: 'lp-1'-- no task armedassert NudgeLoop(...) is NoneTask cancelling-- the second observable of that test, which the first assertion would otherwise have short-circuitedassert 9999999999.0 != 9999999999.0-- the assertion added because that write was otherwise unobservableassert 200 == 409assert 200 == 409to be undefinedexpected <button> to be nullfeature disabled: one event,unknown loop: one event,structured monitor: one event-- so each guard is proven independently rather than only the firstsuccessassert 'success' == 'denied'assert '' == 'lp-1'420pxpopover spans x=0..420 in a 320px viewport,trigger spans x=310..403assert 200 == 503, and the order list showingcritical=Falseassert 200 == 503fired despite an unwritable audit sink, and['fire', 'audit:invoked...']04 the trigger is now disabledfails: nothing says the cycle is already armedSaveagainStart loopabsent, and the active case asserted too so the fix cannot just move the confusion04 the line now reads duefails: the countdown keeps showing the superseded cycleawaitinfire_nowfire_now suspended. Any await between the guards and the arm reopens ...to have a length of 2 but got 3-- the placement assertion; the other four are locator ambiguity because this mutation is additive rather than a clean move, so they are the mutation's shape and not evidence about those testsA vacuity in my own test, found by mutation and fixed. The first version of
the "close the popover" mutation reddened only one test, not the
edit-survives one -- because the test helper hardcoded
open={true}, so theharness pinned the popover's presence and a close could not be observed. The
helper now drives
openas a controlled prop the way the real parent does, andthe same mutation reddens both.
A change I could not justify by measurement, and said so instead of quietly
keeping it. I added
flex-wrapto the schedule row alongside the width cap,expecting it to be the thing that keeps the trigger on screen. Mutation says
otherwise: pinning the shell back to 420px reddens the narrow frame, while
removing the wrap changes nothing at 320px. I also wrote an assertion on the text
column to justify it, and that assertion FAILED in the shipping state too (161px
either way against a threshold I had picked at 180px) -- a gate that is simply
wrong, so it is deleted rather than tuned until it passed. The wrap is kept for
string length, since
shrink-0protects the button and a longer localizedcountdown has only this row to give, and its comment now says that instead of
claiming to be the fix.
A test with an escape hatch, found by mutation. The first version of the
remove-race test branched on the returned status and asserted final state --
but "the loop is gone AND a timer was armed" is true both when the fire
legitimately armed first and when it resurrected a removed loop, so removing the
lock did not redden it. The second version asserted ORDER, and still did not
redden: one
sleep(0)does not let the removal commit, because the lockacquisition is several awaits deep, so the dangerous interleaving was never
reached. Only the third version -- draining until the removal actually lands --
measures anything. Two passing mutations in a row on the same assertion, each of
which I had to treat as a MISSING measurement rather than a green one.
A mutation that passed and therefore proved nothing. My first ordering
mutation assigned
svc.fire_nowas a reference instead of moving the call, so itchanged no behaviour and the suite passed. A passing mutation is a MISSING
measurement, not a green one; re-done as a real swap, it reddens both tests.
A second self-inflicted miss worth recording. My mutation harness restores
the file from
HEAD, which is only correct when the code under test iscommitted. It was not, so the first mutation round silently reverted the audit
fix itself; the tests then failed, which is how I noticed. That accident doubled
as a whole-fix mutation (both new tests fail with the fix absent), but the
sequence is commit-then-mutate and I ran it out of order.
The predicted doc collision happened, and both sides are kept. #8936 landed
on main and edited the same Monitor loops row and the same spec paragraph this
change touches -- the collision this description has flagged since the first
revision. Resolved by taking main's line and re-applying my insertion into it,
not by choosing a side: the feature-map row keeps main's "also surfaced read-only
on the Crew Members drawer" wording AND gains the fire route, and the spec
paragraph keeps main's new prose AND my clause, re-applied verbatim from my own
side rather than retyped. Both resolutions assert their anchors and assert that a
sample of main's new text survived, so silently dropping the other side would
fail rather than merge.
Rebased onto
36ffa67eato pick up someone else's test fix. Shard 4 wasfailing on two tests in
test_snapshot.py::TestNotificationCopyWhenNoLiveFileExists-- and on six other open PRs at the same time, two of them on the identical tests
with identical assertion messages, which is what established it was not this
diff.
fix(test): scope the notification-copy ordering triggers to the destination open (#8992)had already landed on main but this branch was based before it. Therebase is clean: one commit, the same 27 files, and main's 17-file delta has ZERO
overlap with them (verified with an injected canary so the detector is known to
fire). That class now passes 26/26 and the suite 130/130 locally.
Two CI reds on an earlier head, neither attributable to this diff, both
checked rather than assumed.
Coverage Gateis derived -- its own log readsbackend-test=failure -- failing closed-- so it was one failure, not two. Thatone failure was
test_snapshot.py::...test_a_FRESH_gateway_still_orders_the_copy_against_a_delivery,a race detector with a 1.0s window: it passes 129/129 locally, was introduced by
#8576 BEFORE this branch's base, and drives
dashboard_state._notification_io_pool-- a module singleton in a file this diffdoes not touch, and a different pool from the loop default executor the new
asyncio.to_threaduses. Separately,test_autonudge_reconciler.pyerrors 17times locally on
fixture 'event_loop' not found; that reproduces with 17identical errors on a pristine base worktree with these files absent, so it is an
inherited harness gap and not part of the CI failing set.
Gates run locally, exit codes read without a pipe: black gate (24 files in
scope) 0, flake8 0, isort 0, error-code contract 0,
docs-lint.sh0, brand-namegate 0, the feature-map gate 0 in diff-scoped mode, 233 neighbouring autonudge
tests 0, the 266-test
test_sel.pysuite 0, the 129-testtest_snapshot.pysuite 0, the i18n chain 0 including its diff-scoped zero-tolerance checks(
[added-lines] 0,[vs-base] 0,[unit-added-lines] 0),tsc --noEmit0,eslint 0,
vite build0,merge-treeclean. mypy reports 4 errors intranscribe.pyandcloudwatch.py-- identical count and files on a pristinebase worktree in the same harness, and none of the three touched
src/filesappears anywhere in its output, so they are inherited.
Manual verification
website/scripts/capture-goal-trigger-nudge-8212.mjsdrives the real built SPAbehind
serveDistwith every/api/**call answered from fixtures, andasserts as well as photographs -- a PNG cannot fail, so it exits non-zero
unless each frame renders what this PR claims. 10 assertions, all passing,
including that the Stop/Save row holds exactly two buttons and that the typed
edit survives a refusal. Labels are read from the catalogue, so a key rename
breaks the capture loudly instead of silently photographing the wrong element.
Screenshots / video
Armed loop -- the button sits on the schedule line beside the countdown it
acts on, and the action row below is back to two controls.
Paused loop -- the control frame. The button is absent, because every terminal
bound leaves the loop inactive and the server refuses to fire one, so a button
there could only ever produce a 409. Without this frame the one above proves
only that a button can render, not that it renders when it should.
Refused during a live turn -- the reason names the outcome and the next step,
the popover stays open, and the typed-but-unsaved goal is still there. No still
of a button can show any of that, which is why it is photographed rather than
argued.
A successful press -- the claim that had no evidence until this round. The
schedule line reads "Next cycle due, fires after the current turn", the popover
is still open, and the unsaved edit is still in the box.
The 320px floor. The trigger wraps below the schedule line, fully inside the
viewport and pressable; nothing is clipped.
The press now acknowledges itself
A successful press left the button re-enabled and unchanged, so the only signal
was the schedule line's wording -- and a usability reader would not press it
again because they could not tell whether that would double the nudge. It would
not: the cycle is already armed. The button is now disabled while a cycle is due,
derived from the same countdown the schedule line renders so the two can never
disagree, and with no new string. Frame 4 asserts the disabled state, and the
capture drops to 20/21 without it.
Two further UX notes are declined with reasons rather than silently. "Stop loop"
being a
DELETEwith no confirm is real, but it is PRE-EXISTING behaviour thatthis change only made more visible by putting "Paused" beside it; hiding or
relabelling a destructive control is its own change with its own review, not a
rider on a trigger button. And a "Save & start loop" label to disambiguate resume
would need a new key in thirteen catalogues for a nuance the existing
start_loopstring already carries adequately.Three advisory findings taken, one of them load-bearing
All six readiness conjuncts held on the previous head with every reviewer lane
green -- and three of those reviewers said CONCERNS behind the green, one of them
correctly. The spec paragraph in this same commit still described the
superseded design: it said
fire_now"movesnext_due_tsto now" and that thevalue "is still written, so a restart ... resumes overdue", while the shipped
code deliberately writes nothing. The spec is the mandated first read for this
subsystem, so a maintainer would have reasoned from restart semantics the code
abandoned -- the exact property five rounds fought over. Rewritten to the
write-free design, with assertions that the superseded phrases are gone rather
than merely that new ones are present.
A paused loop had no visible way out. The primary button silently PATCHes
active: truebut read "Save", so a reader found no resume control and calledboth "Paused" and "Stop loop" risky. It now reads Start loop when the loop is
inactive, gated on
activerather than on existence -- which was the bug -- andit reuses the
start_loopkey the no-loop case already uses, so no cataloguegains a string.
A successful press used to relocate the button under the cursor, because the
longer "due" wording made the row wrap. The schedule block now stacks in every
state, so the button occupies its own line at all widths and the narrow frame and
the desktop frame are one layout instead of two to keep in sync.
Not taken: First Principles notes that structured monitors share the root cause
and are refused toward a monitor API that has no fire route. That is accurate --
a monitor fire runs through
_on_monitor_tickand is genuinely a larger change --so it is deferred rather than smuggled in here.
Other suggestions / review dispositions
Taken from the SEVENTH round (GPT 5.6, one blocking, fenced and upheld): the
pre-persist
_firingguard and the post-persist re-arm straddle a suspensionwindow the mutation lock does not cover, because the timer does not take that
lock. Correct, and mine.
The pattern is worth a maintainer's attention more than any single finding
here. Rounds 4 through 7 were each caused by the fix before them: making the
deadline durable added an await; the await opened a remove race; closing that
with a transaction still missed the cancellation shield; and the transaction does
not cover the timer, which is this round. Every finding was real and none was
overridden -- but the durability requirement that started the chain is the one
the adjudicator itself judged advisory, and four concurrency changes to a hot
path is a larger diff than the feature warrants. A maintainer may reasonably
prefer the synchronous
_persist_soonform from the second revision, which hasnone of these windows because it has no suspension point, and accept the
documented "a lost write degrades to a fresh full countdown" trade that the other
eleven callers in this file already accept.
Taken from the SIXTH round (GPT 5.6, two blocking, both upheld): the shielded-task
contract this file already uses, and the 320px floor. Both were mine, and the
first is the same shape as the round before it -- I fixed a concurrency hazard by
adding a transaction and still missed the cancellation shield its two siblings
carry a few hundred lines away. Six rounds in, the pattern in this PR is that
every concurrency fix has needed the next one; the reviewer has been right each
time and none of these was overridden.
Taken from the FIFTH round (GPT 5.6, two blocking, both mine and both caused by
the previous rounds' fixes): the durable-persist await opened a remove race, and
the refusal audits ran a possibly-initializing SEL call on the event loop. Worth
stating plainly, because it is the honest shape of this PR's history: rounds 3,
4 and 5 were each created by the fix before them, and the round-4 change --
which the adjudicator itself thought advisory -- is what introduced the
upheld-blocking race. The property Opus verified in round 1, that the guards are
synchronous with no awaits between check and mutate, is exactly what I broke and
have now restored by other means.
Taken from the FOURTH round (GPT 5.6, blocking, security-class): the new deadline
was acknowledged with a
200before it was durable. Worth recording that thebot's OWN adjudicator argued this should be advisory -- it found the crash window
sub-millisecond and the degradation self-correcting, and concluded the finding
"clears the FLAG bar rather than the unbounded-harm bar the fence assumed" -- but
the security fence withholds such findings from adjudication, so the blocking
verdict stood. I fixed it rather than seeking an override, because checking it at
source turned up an argument neither the finding nor the adjudication made: the
previous round's
critical=Trueaudit means a lost deadline write now leaves adurable record of a fire that never happened. My first instinct -- that awaiting
would diverge from the subsystem -- was wrong, and
_persist_soon's owndocstring is what corrected it.
Taken from the THIRD round (GPT 5.6, blocking, security-class and fenced): the
fire ran before its audit was guaranteed. This one was a direct consequence of
the previous round's fix -- adding the refusal audits put a non-critical write on
the success path too -- and the finding is correct:
criticalis what separatesan audit gate from an audit hope. Adopted in the repository's established form
rather than invented, and the ordering, the durability and the fail-closed
direction are each pinned by their own mutation.
Taken from the SECOND round (GPT 5.6, both blocking, both mine): the missing
feature-map row, and the four early refusals that denied a request without
emitting a SEL event. Neither was a false positive and neither is overridden.
Taken from the first round: the AUTOSDE row cap (blocking, GPT), the silent loss of an
unsaved edit and the jargon refusal string (UX, advisory), and the missing spec
route (Design, advisory -- and an AGENTS.md MUST I had violated).
Declined, with the mechanism rather than a preference. Design suggests
dropping the busy pre-check entirely and letting the existing
refuse-and-rearm-with-backoff machinery deliver the nudge after the turn ends.
It is a real argument -- the "3KB+" reason is about injection-queueing rather
than deadline-arming -- but it costs two things. The press would return 200 and
visibly do nothing, which First Principles independently named as a harm in this
same round; and the refused fire re-arms via
_arm_timer(delay=backoff), whichdoes not touch
next_due_ts, so the popover would read "due" while the realnext attempt is a backoff away -- a displayed deadline that is wrong. A visible
409 costs one pre-check and lies about nothing.
Also declined: a
titleon the button naming the cycle cost. Leaving thepopover open already makes that observable -- the header's cycle readout advances
when the fire delivers -- so a new key in twelve catalogues to restate it is not
earned.
Residue, named rather than left implicit. The refusal is still English prose
rendered verbatim, as all 13 error bodies in
handlers/autonudge.pyare. PerRFC 9457 the
codeis the contract anderroris advisory; localizing from thecode is the right fix and no autonudge consumer implements it, so that belongs to
all 13 at once rather than to a one-off divergence here.
Related Issues
Refs #8212
Not related, recorded so a reader does not conflate them: #8926 is a timeliness
defect in the observation-gated wake path. This change does not touch
_monitor_tick_is_quiet, and the popover never addresses a structured monitor(
api_autonudge_getfilters them out, and the new route refuses one).Checklist