feat(members): show each member's auto-patrol (monitor loop) status - #8936
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Established conventions confirmed: Design-Verdict: PASS A real incident (a member's dead patrol invisible for a night), solved at the right layer: one live registry read, shared readouts, stopped-state never collapsed into "none". [DESIGN-REVIEWED] 8f5bc19 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All facts are verified. The screenshots/harness follow the repo's documented PR convention (414 sibling First-Principles-Verdict: CONCERNS Every surface earns its incident-derived place except two: a What this change shipsIntent: make a member's silently dead auto-nudge loop visible on the Crew Members page — an ADDITION (observability), honestly framed; the incident's cause is fixed in a declared sister PR.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 8f5bc19 |
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/pages/members/MembersPage.tsx:1127 -- A pending or failed patrol read makes False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of I have everything I need. The reconciliation checks out: all three verdicts, the badge, both themes, loading and error states are screenshotted; both animated transitions have committed recordings; the blind reader correctly identified the patrol block, badge states, intervals, wake counts, and every stop reason. The residual risks are the stalled-approval dead end, the two dot systems merging, and the doubled error notice. UX-Verdict: CONCERNS Comprehension of the patrol block is solid, but the stalled-approval verdict points users at "this conversation" and then strands them there. Watch
Suggestions
[UX-REVIEWED] 8f5bc19 |
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: |
be5147f to
e2ba61d
Compare
e2ba61d to
0ea2b7a
Compare
0ea2b7a to
310437b
Compare
21649cd to
8f59f70
Compare
The Crew Members page had no reference to the auto-nudge service at all, so a standing member whose loop had stopped or never armed looked identical to one patrolling normally. The conductor member thread went unwoken for a whole night and nothing on its own page said so. The detail drawer gains an "Auto patrol" block beside "Sessions it's driving", a React Query read of the existing GET /api/autonudge registry filtered to the member's derived slot key (member-<slug>). The websocket hook invalidates that query on every autonudge_state frame and on every (re)connect, and a one-minute interval floors it, so a stop that landed while the socket was down is re-read rather than trusted stale. Three verdicts, never conflated: an active loop shows interval, wakes (bare count under an unlimited cap), last and next wake and the banner-or-instruction line; a stopped loop keeps its stopped_reason visible instead of collapsing into "nothing scheduled"; otherwise "No patrol scheduled." Loading renders a skeleton; a failed read renders the shared ErrorNotice. A refused arm is reserved in the module spec as an optional `denied` field with clearing semantics and a code vocabulary, but no backend emits it yet, so nothing here renders one. An active patrol is also listed under Wake sources, so the card cannot say "Last wake 6m ago" above "Nothing wakes this member automatically". The roster avatar carries a patrol badge (the composer's goal-chip glyph) whenever a loop record exists — accent while it patrols, warn once it has stopped, because the dead patrol is the case that must show at a glance — top-right, so it coexists with the bottom-right presence dot and the row's right-edge markers. The cycle readout and the deadline-preserving countdown move into a shared module (components/autoNudgeLoop.ts) that AutoNudgePopover now consumes too, so the two surfaces cannot drift apart. Twenty-one new catalog keys in all 13 locales plus the regenerated pseudolocale; feature map and module spec updated; capture harness scripts/capture-members-patrol.mjs (eight stills, two recordings of the animated state changes).
8f59f70 to
8f5bc19
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review — approved.
Accuracy of the displayed verdict. The three states are derived from the record itself, not inferred: active from loop.active, stopped from a record that exists but is inactive, none only from a genuine absence. Crucially loaded/failed are kept distinct from empty (data === undefined && isError), so a failed registry read can never render the affirmative "No patrol scheduled" — which is the exact false statement this feature exists to prevent — and the same failure is surfaced at the roster level too, so silently blanked badges cannot read as "no member has a patrol". A stopped loop keeps its stopped_reason instead of collapsing into "none". Liveness is real: the WS hook invalidates AUTONUDGE_LOOPS_QUERY_KEY on every autonudge_state frame and on every (re)connect, with a 60s refetchInterval as the floor, so a stop that landed while the socket was down is re-read rather than trusted stale. An unmapped stopped_reason falls back to the raw code rather than to a sentence no producer emits.
No cross-slot leakage. patrolLoopOf and activePatrol are exact-key dictionary lookups against the member's derived member-<slug> slot (slots[m.name] || m.slot_key) — no prefix or fuzzy match — so a loop on any other slot (including slack:/discord: session loops, which are the only records that could belong to a different human) is structurally unrepresentable in a member's block or badge. Pinned by a test. GET /api/autonudge was already fetched wholesale by the existing seedGoalLoops path, so this adds no new endpoint or auth surface; the message/banner it now renders is an owner-scoped member-slot instruction already redacted at the arm chokepoint.
Scope. One coherent feature: read + drawer block + roster badge + shared readouts, plus the mandated same-commit docs rows, the 13 i18n catalogs, and the repo-standard capture harness / temp-screenshots evidence. The PR Scope warning (1366 lines / 7 areas) is line-count breadth of one feature, not independent work that could be split. No src/ backend change.
i18n. 22 new keys under pages.membersPage.patrol_* / wake_patrol_every present in all 13 catalogs plus regenerated en-XA; every user-visible string goes through t() — no hardcoded English in the JSX — and the {{n}}/{{max}}/{{time}}/{{cycle}} interpolations match their call sites.
Findings. Zero blocking. GPT 5.6 and Opus 4.8: no blocking findings. Design: PASS. First Principles and UX: CONCERNS, explicitly advisory — and both residuals (cycleText having one consumer; the denied spec reservation with no producer) are dispositioned in the PR body, with the design lane having asked for the reservation to be pinned. The UX watch items (stalled-approval dead end, two dot systems on one avatar, doubled ErrorNotice link, 15s countdown tick) are polish on a state this PR only reports, and the restart affordance correctly belongs to the sister backend PR. None of it justifies withholding an observability fix for an incident that already cost a night of silence.
Since #8936 a member's avatar carried a badge whenever an auto-patrol record existed for its thread: accent while active, warn-yellow with a pause glyph once the loop had stopped. Stopped is the resting state of most members (every cycle-capped or hand-stopped loop leaves a record), so most idle avatars wore a permanent yellow mark that read as "this member is broken" rather than "nothing is scheduled". The roster badge is now two-state: the accent goal glyph with its "On patrol · 3 of 24" tooltip while the loop is ACTIVE, and no badge at all otherwise — a stopped loop and a never-armed member look the same at the roster, the way the presence dot (#6859) and unread dot already work: appearance is the signal. The drawer's Auto patrol block keeps its full three verdicts (active / stopped with reason and last wake / none); the stop reason lives there. Fade-out on stop stays through AnimatePresence (no hard vanish, #8718); `useReducedMotion` cuts the tween under prefers-reduced-motion. The `patrol_badge_stopped` key is removed from all 13 catalogs and en-XA is regenerated. Tests pin one badge on a mixed active/stopped/never roster and no intermediate "stopped" badge when a loop stops in place. The capture harness asserts exactly one badge per active loop. Closes #9420
Since #8936 a member's avatar carried a badge whenever an auto-patrol record existed for its thread: accent while active, warn-yellow with a pause glyph once the loop had stopped. Stopped is the resting state of most members (every cycle-capped or hand-stopped loop leaves a record), so most idle avatars wore a permanent yellow mark that read as "this member is broken" rather than "nothing is scheduled". The roster badge is now two-state: the accent goal glyph with its "On patrol · 3 of 24" tooltip while the loop is ACTIVE, and no badge at all otherwise — a stopped loop and a never-armed member look the same at the roster, the way the presence dot (#6859) and unread dot already work: appearance is the signal. The drawer's Auto patrol block keeps its full three verdicts (active / stopped with reason and last wake / none); the stop reason lives there. Fade-out on stop stays through AnimatePresence (no hard vanish, #8718); `useReducedMotion` cuts the tween under prefers-reduced-motion. The `patrol_badge_stopped` key is removed from all 13 catalogs and en-XA is regenerated. Tests pin one badge on a mixed active/stopped/never roster and no intermediate "stopped" badge when a loop stops in place. The capture harness asserts exactly one badge per active loop. Closes #9420
Why
A real failure, not a hypothetical: the conductor member thread
member-kirocrew-conductorstopped being woken last night and stayed silent until a person noticed the next morning. Nothing on the Crew Members page could have said so —website/src/pages/members/MembersPage.tsxhad zero references to the auto-nudge service. Whether a member has an active loop, when it last woke, how many cycles it has run, or whether the last attempt to arm one was refused was invisible on the one page whose whole purpose is "is this standing member alive". The conductor died for a night and nobody knew because monitor state was not shown where the member lives.This PR is the frontend half. It reads the existing
GET /api/autonudgeregistry and does not depend on the backend fix that lets a member thread host its own loop (sister PRfix/autonudge-member-self-loop).What
Detail drawer: an "Auto patrol" block next to "Sessions it's driving", filtered to the member's derived slot key (
member-<slug>). Three verdicts, never conflated:3 of 24in the drawer — the badge keeps the compact3/24— or61 · no limitunder an unlimited cap), last wake (relative, absolute in the hover title), next wake ("Due in 13m 58s", the same deadline-preserving reading as the composer's goal chip), and the banner or the instruction's first line.stopped_reason(wake limit, time budget, a tool approval left waiting in this conversation — the one open on screen — paused by hand; a code without a sentence renders as itself) and the last wake. A stopped loop is deliberately NOT collapsed into "nothing scheduled": that collapse is exactly how a dead patrol goes unnoticed.A refused arm is reserved, not rendered:
docs/system-specs/modules/learn-cron-dashboard.mdpins an optionaldenied: [{slot_key, code, reason, ts}]on the list response — one entry per slot, cleared by a successful arm so a refusal can never mask a later stop, with the code vocabulary drawn from the arm chokepoint's refusal branches — for the sister PRfix/autonudge-member-self-loopto produce. No backend emits it today, so this PR types and renders nothing for it (round-2 consensus across GPT, design, first-principles and UX: a consumer must not ship a verdict with zero producers).Data path. One React Query read (
AUTONUDGE_LOOPS_QUERY_KEY), not a private fetch plus a frame merge.useWebSocketinvalidates that key on everyautonudge_stateframe and on every (re)connect (same placeseedGoalLoopsruns), and a 60srefetchIntervalfloors it — so a stop that landed while the socket was down is re-read rather than trusted stale, and a transient mount-time failure recovers on the next signal. A refetch error after a good read keeps the last verdict. Loading renders a skeleton; a failed first read renders the sharedErrorNotice(inline,askAgent) in the drawer block AND at the roster level — a failed read blanks every badge, which must not read as "no member has a patrol" — both captured below.Wake sources now lists an active patrol as a source ("Auto patrol · Every 20m"), so the card cannot say "Last wake 6m ago" above "Nothing wakes this member automatically".
Roster: a patrol badge on the avatar whenever a loop record exists — the goal-chip glyph, solid accent fill with the goal glyph while patrolling, solid warn fill with a pause glyph once stopped (the presence dot's fill idiom; shape as well as colour, so the two states read apart without the hover) (the dead patrol is the incident's at-a-glance case, so it must show at the roster, not only in the drawer), at the top-right corner so it coexists with the bottom-right presence dot and with the row's right-edge markers (unread dot; the
needs_youpill from #8614). It animates in/out and the colour flip is transitioned; the drawer block cross-fades on a verdict change.data-testid="member-patrol-dot"withdata-state;aria-labelcarries the wake readout in the drawer's own spelling ("On patrol · 3 of 24") or "Patrol stopped".Shared readouts. The cycle spelling and the next-fire countdown move into
website/src/components/autoNudgeLoop.ts;AutoNudgePopovernow consumes them, so the composer chip and the drawer cannot drift into two spellings. The popover'sAutoNudgeLooptype is re-exported unchanged for its importers.Docs. Feature map rows for Crew Members and Monitor loops updated; module spec pins the list-read contract and the optional
deniedfield.i18n. 21 new keys under
pages.membersPage.patrol_*/wake_patrol_everyin all 13 catalogs (verified withi18n-translate.mjs verify, 0 findings per locale, hi/bn in तुम/তুমি form) plus the regenerateden-XA. Keys inserted line-anchored into each catalog'smembersPageblock, same as #8591 —mergewould re-sort every 14k-line catalog.Not touched: the composer (owned by #8852).
Screenshots and recordings
Capture harness:
website/scripts/capture-members-patrol.mjs(real built SPA overstubDashboardApi, no gateway). Every frame asserts the block'sdata-stateand the roster badge count before it is written; the recordings drive the production path — the harness holds the page's/api/wssocket and pushes realautonudge_stateframes.Active loop, dark — radar patrolling 3/24, badge on its avatar beside the green presence dot, patrol listed under Wake sources:
Same state, light:
Unlimited cap — cycles read "61 · no cap":
Stopped at its cycle cap — scout carries the warn badge:
Stopped because a tool approval went unanswered:
Nothing scheduled:
Registry read in flight (skeleton) and failed (shared
ErrorNoticewith the agent hand-off, on the roster and in the drawer block):Roster badge on fixer's avatar arming (accent), flipping to stopped (warn), then disarming — driven by real
autonudge_stateframes:Drawer block cross-fading none → active → stopped:
Tests
MembersPage.test.tsxgains a describe pinning: active loop readouts (finite and unlimited cap — the latter caught a{{n}}/countinterpolation bug in round 1 — banner vs instruction first line, next-wake wording), a loop on another slot not leaking in, a stopped loop keeping its reason, the patrol listed under Wake sources and the empty line surviving without one, a failed first read renderingErrorNotice, a failed refetch keeping the last verdict, the roster badge accent on the active member and warn on the stopped one beside the presence dot, and query invalidation (what the websocket hook does) arming and disarming the badge. Local:tsc -b, eslint,npm run i18n:check(withI18N_BASE_REF=origin/main), brand gate, docs-lint all green; vitest left to CI.Review round 2
ErrorNotice. GPT finding / Opus finding: React Query read +{ n }interpolation.deniedshape pinned in the module spec.approval_stalleddropped from the type;deniedkept (task scope) but pinned and code-mapped.{{n}}bug, badge visibility (ring + 16px), stopped/none dead-end (arm hint), unlimited-cap screenshot and the two recordings added.Review round 3
durationPartsunexported. Two client copies of the registry (reduxgoalLoopsframe-merge vs this query) acknowledged and deferred, as the lane suggested.cronsmockRejectedValuefrom an earlier wake-sources case leaked into the new describe; it now resets its mocks.Review round 4
bg-bg-hoverand its capture waits for the drawer spring to settle; "A tool approval went unanswered" reworded to name where ("Stopped waiting for a tool approval in its conversation"); stale captions fixed.autonudge_stop→ sentence mapping and its key removed (only research loops are stamped with it today; the raw-code fallback covers a future producer). Thedeniedspec reservation is kept on purpose — the design lane asked for it to be pinned before the sister PR lands — see the per-lane disposition comment.fix/autonudge-member-self-loop.Review round 5
deniedspec reservation and the two reconnect read paths are dispositioned (kept / deferred) in per-lane comments rather than changed.test_main_ratchet_audit,test_snapshottiming assertions in shards 2/4 + Coverage Gate) are not this branch's: it touches nosrc/ortest/file, and main's own run atd5955744ffailed the same shard and gate. Rebased onto0d65dc969.Review round 6
errors-use-error-notice, roster badges going silently blank on a failed registry read): a roster-levelErrorNoticenow renders beside the member count whenever the read fails, drawer open or not; pinned by a test with no drawer open.Backend Tests (3.12, 4)+ Coverage Gate ontest_snapshotconcurrency assertions again — nosrc//test/change on this branch; main'sd5955744frun failed the same shard and gate.Review round 7
fix/autonudge-member-self-loop(the only arm path is refused for member slots today); a capped-out loop wearing warn is accepted — the cap is a stop the person set, and the reason line says so.autoNudgeLoop.tsheader no longer claims a shared "3/24" spelling;cycleTextstays beside the record it reads (one consumer today). Thedeniedreservation stays — the design lane asked for it and passed with it.Backend Tests (3.12, 4)on the sametest_snapshotconcurrency assertion as rounds 4–5 — inherited (no backend change here; main'sd5955744ffailed the same shard).