docs(spec): one paradigm for every monitoring loop - #9368
Conversation
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: |
Design Review (Fable 5) — ✅ PASSDesign-level review of The lint can't run here (approval-gated), but it's CI-enforced and line-level regardless. I have what I need: this is a docs-only PR adding an umbrella spec that follows an already-landed RFC (#9339), correctly indexed in both routing tables, with its code-facts (protocol names, module line counts, file layout) verifying against the tree, live links, an explicit status table separating target from present, and a falsifiable acceptance test. The known staleness (babysit-pr-watch.md's opener) and the same-session-wake deviation are both acknowledged with owners deferred, not papered over. Design-Verdict: PASS A contract spec for a landed RFC, correctly layered above the two implementation specs, with target-vs-present status made explicit — no design-level concerns. [DESIGN-REVIEWED] d8b0166 |
4bb2083 to
8508bb6
Compare
Adds the umbrella spec the two existing monitoring specs sit under, so a new monitored kind has a contract to implement rather than an existing watch to copy. Seven layers, one owner each, and six of them never learn what is being watched. The consequential contracts: - The probe signature is PLURAL from day one. A per-subject interface cannot be batched later without changing every implementation and every caller, and the difference is roughly 150 process invocations against one query for fifty subjects. A probe that cannot batch loops internally so the caller never encodes the difference. - An observation is a NAMED entry with a severity and a reset scope, never a bare fingerprint. A hash cannot be deduplicated per condition, coalesced with a sibling, or re-asserted, because nothing can tell whether two hashes describe the same condition. - The decision layer is a pure function whose clock arrives as a value, and it is level-triggered. Edge triggering loses any condition that stayed true across a wake that did not happen. The re-alert window makes that affordable and the budget makes it safe: a notification pipeline aimed at humans needs no token budget because a paged human self-limits. - Persisted state holds delivery bookkeeping only, versioned with a migration per bump. Subject state belongs in the disposable evidence file. - An out-of-session driver is a detector, never a reactor. A cron turn has no owning slot, so its tool calls hit deny-by-default and time out while the job still records healthy. Also records the rules that must be enforced by code rather than by prose, including one the two current implementations already disagree on: the status tool collapses superseded check attempts to the newest per identity while the structured provider treats each row independently and maps CANCELLED to failed, so it can wake on a failure that no longer exists. Status is per layer, verified at 2f9ed97, because most of this is the target rather than a description of what runs today.
8508bb6 to
d8b0166
Compare
|
Design Review CONCERNS on Watch -- commit-pinned code census will rot silently. Accepted, fixed. The finding names a real rule:
One part not changed, deliberately: The PR body still carries counts. That is intentional: a PR body describes one moment by nature, and editing it would re-roll a review lane that is currently clean on this head for no gate benefit. Suggestion -- routing row. Accepted, added. There was no row for this area at all, so nothing routed a new-loop author to the contract before they copied an existing watch, which is the problem this spec is meant to solve.
|
bolichen97
left a comment
There was a problem hiding this comment.
I checked every code claim in this spec against the tree rather than reading it for coherence, and most of it holds: the seven MonitorDecision values, decide_monitor returning the enum bare, both Protocols typed to GitHubPullRequestProbeResult (controller.py:87, shadow.py:33), objective as a plain str, all three allowlist boundaries, NudgeLoop.gate/monitor and is_structured_monitor_loop with its real branch sites, probes/__init__.py's single-if build(), irq.py state genuinely unversioned, and the deadline-preserving cadence at autonudge.py:560-570. Every proposed/target row is labelled correctly, and resets_on/budgets_spent are correctly absent from the tree. No leaked internal references; the file is all printable ASCII.
The problem is the premise the document rests on.
-
"There is nothing to extend" is false, and it is the load-bearing sentence. The scoped part is right — no base class, no
ABC, noabstractmethodanywhere inmonitoring/. The conclusion is not: "a second kind has nothing to subclass and no hook to implement. The extension point does not exist yet, so the work described here is to create one, not to conform to one."irq.py:255isclass Probe, docstring "Domain half of a watch. Subclass and implement both methods," with two required hooks raisingNotImplementedError(irq.py:276,irq.py:284) plus optionaltuning()andwake_suffix().probes/gh_pr.py:302isclass PrWatchProbe(Probe)— today's one kind, already conforming. The spec's own status table creditsprobes/__init__.py, so the two sections disagree with each other.This matters more than a wording slip because of the new
AGENTS.mdrouting row: an author sent here to add a second kind is told to create an extension point, when today's actual first step is subclassingirq.Probeand adding a branch inprobes/__init__.build. Stopping that author from copying an existing watch blindly is the document's whole purpose. -
MonitorObservation.canonicaldoes not exist.MonitorObservation(models.py:221) has exactly seven fields —fingerprint,status,provider_error,supplemental_provider_error,reason_code,summary,head_changed.format_monitor_wake(controller.py:244) takescanonicalas a parameter, passed atcontroller.py:200ascanonical=state.last_observation; that dict originates asGitHubPullRequestProbeResult.canonical(github_pull_request.py:122) and is copied in atautonudge.py:2412. The correct attribution isMonitorState.last_observation.MonitorState.wake_instructionsis right. -
Layer 3 attributes the named-entry vocabulary to the wrong file. "Named entries exist in
probes/gh_pr.py" — the type and the vocabulary are in the kernel:Observation(key, severity, brief, epoch_scoped)atirq.py:193andSeverity(WAKE, TERMINAL, NMI)atirq.py:172. The spec's proposedObservation(key, severity, resets_on, brief="")withIMMEDIATEandREVISION/NEVERis a rename of that existing type (epoch_scoped: bool→resets_on,NMI→IMMEDIATE), which the document never says. Presenting a rename as a new type hides that the migration has existing callers. -
"the only consecutive counter in the package is
consecutive_provider_errors" is false. The same dataclass carriesquiet_streak("Consecutive quiet observations since the last delivered turn") withfloor_ticksrecording the deliveries it forces, andirq.pyhas its own consecutive-error backstop. The narrower claim this sentence exists to support — no engine state holds a streak of identical verdicts — survives; the "only consecutive counter" wording does not. -
The Decision row understates
irq.py. "Edge-triggered and with no coalescing" is true ofmonitoring/decision.pybut not of "every monitoring loop", which is this doc's stated scope.irq.pyalready level-triggers on the live cron path:DEFAULT_REALERT_SECS = 6 * 3600(irq.py:97), per-keyalertedtimestamps inload_state,_dedupe_keywith epoch/sticky sentinels (irq.py:132), acoalesce_secswindow, andSeverity.NMIdocumented as bypassing the delay but not the mask (irq.py:172). So the re-assert-after-a-window behaviour is presented as a target the code lacks, when one engine already has it. -
"gives every check row its own group key" is overstated. Only
CheckRunrows do —_normalize_checkreturns aNonegroup key for them (github_pull_request.py:497) and_normalize_checkssubstitutes("independent_check_run", str(row_index))(:449);StatusContextrows are grouped by("status_context", context)(:513). The defect the sentence describes is real:CANCELLEDmaps to"failed"(:488) and themin(...)state fold prefers"failed", so a superseded cancelled attempt reads as a live failure.
Requesting changes rather than commenting because 1 misdirects the exact reader the new routing row sends here, and because 2 and 3 name symbols that do not exist or live elsewhere. The Design round replaced pinned line counts with named symbols precisely because symbols are the durable form — but a wrong symbol name is harder to spot than a stale count, and the docs lint cannot catch it: its dead-identifier pass reported only resets_on and budgets_spent, both intentional. CI is green and all five lanes pass on d8b01667c, so none of this was caught upstream.
Follows #9339, which landed the RFC this spec is the contract for.
Problem
A new monitoring loop has no contract to implement, only an existing watch to copy. The two that exist disagree about nearly every layer, so which one gets copied decides what the new loop can do -- whether it can say what changed, whether it can be batched, whether it re-asserts a condition it missed, whether it stops when stuck.
Neither existing spec is the umbrella.
agent-interrupt-controller.mdspecifies the kernel behind script-cron pollers andbabysit-pr-watch.mdspecifies the pull-request watch built on it; both describe an implementation rather than the paradigm.Solution
Adds
docs/system-specs/modules/monitor-architecture.mdas the umbrella above those two, with the index row placing it there.The goal is a substrate, and the spec now says so in Purpose. Any loop with an external subject plugs in -- a pipeline run, a ticket, a deployment, an alarm, a queue depth -- and a pull request is the FIRST PLUGIN, not the subject matter. Where a layer names a pull request it is naming today's only plugin.
Three prerequisites, stated before the layers
Nothing in the seven layers is reachable until these exist, so they come first:
MonitorDecisionis a seven-value enum anddecide_monitorreturns it bare, so a verdict is an effect selector and nothing more -- it cannot say which observations caused it, and it cannot carry what to tell the woken agent. Both get reconstructed afterwards byformat_monitor_wakeout ofMonitorObservation.canonicalandMonitorState.wake_instructions, state the verdict never named. That return type is what forces one fingerprint per subject, not a preference for hashes over names: a verdict with no room for a list has nothing to compare per condition, so the named-entry vocabulary in layer 3 is unreachable until the verdict can hold entries._Providerincontroller.pyandGitHubShadowProviderinshadow.pyare two Protocols declaring the sameprobemethod, and both annotate its return as the concreteGitHubPullRequestProbeResult. The duplication is not the defect; the defect is that both generic boundaries name a GitHub-specific type, so the abstraction is typed to its one concrete implementation.kindandobjectiveare both required with exactly one legal value each. Neither is a Python enum --objectiveis a plainstrfield onMonitorState-- and both are constrained by string allowlists at three separate boundaries: themonitor_watchschema inmcp_tools/control.py, theMONITOR_WATCH_SCHEMAfield specs invalidation.py, and the REST handler indashboard/handlers/autonudge.py. Nothing scopesobjectivetokind, so a shared objective vocabulary means every new kind edits a list it does not own -- the same defect as a dispatch branch wearing a different shape, paid three times.There is nothing to extend
Recorded as a fact about the package, because it is the precise statement that a second kind has nothing to subclass and no hook to implement. Zero
ABC, zeroabstractmethod, no behaviour inheritance. The only inheritance is six enums onstr, Enumand one exception onRuntimeError; the only polymorphism is four Protocols, three of them private. The nine dataclasses and the two plain classesMonitorControllerandGitHubPullRequestProviderhave no base at all.A monitor is a field, not a system
This confuses readers who know the code, so the spec now states it. A monitor is a nullable field on a nudge loop:
NudgeLoopcarriesmonitor: MonitorState | Nonealongsidegate: bool, andgateis the discriminator -- the field's own comment records thatgate=Truerecords belong to the prompt path while controller-owned records carry state withgate=False. One class, three shapes.is_structured_monitor_loopselects the third, and it is the guard the dashboard handlers, the session directive application path and the Slack gateway all branch on. The substrate is therefore a change to what that one field holds, not a new subsystem beside the loop.The decision is split, and only half is pure
decision.pyholds the content policy -- did the subject change, is the budget spent (monitor_budget_reason), is this error retryable (_provider_error_decisionagainst_RETRYABLE_PROVIDER_ERRORS) -- with the clock arriving as itsnowparameter and no IO at all. The delivery policy is the other half and is impure:MonitorController.tickdecides whether a wake is in flight, whether the last dispatch came back busy, and whether the evidence deadline has passed, running the probe off-thread and reading the wall clock through its own injected clock.Two wiring facts a reader will otherwise trip on.
controller.pydoes not importdecide_monitorat all -- its only decision-module import ismonitor_budget_reason; it callsservice.apply_monitor_probe(...)and the service calls the decider. Andterminal_decision_for_outcomerecords in its own docstring thatapply_monitor_probeflattens every terminal outcome toSTOP_BLOCKEDbeforedecide_monitorruns, so that function's branches are unreachable on the live delivery path, reached only byrun_shadow_probeon the persistence-only shadow path. Consolidation merges two halves with different testability rather than lifting one pure function into place.The layer contracts that carry weight
The probe signature is plural from day one. A per-subject interface cannot be batched later without changing every implementation and every caller, and the cost is not marginal: fifty subjects read one at a time is roughly 150 process invocations against one query. Today batching is reachable only from the single out-of-session poller, for no reason other than interface shape.
An observation is a named entry carrying a key, a severity and a reset scope, never a bare fingerprint. A hash cannot be deduplicated per condition, coalesced with a sibling, or re-asserted, because nothing can tell whether two hashes describe the same condition.
The decision layer is level-triggered. Edge triggering loses any condition that stayed true across a wake that did not happen -- a busy session, an exhausted budget -- because on the next tick it is no longer a change. The re-alert window makes level triggering affordable and the budget makes it safe: a notification pipeline aimed at humans needs no token budget because a paged human self-limits, and an agent does not.
Persisted state is versioned with a migration per bump and holds delivery bookkeeping only. Subject state belongs in the disposable evidence file.
An out-of-session driver is a detector, never a reactor. A cron turn has no owning slot, so its tool calls land on a deny-by-default path and time out, while a denied tool inside a completed turn still records the job as healthy.
An acceptance test that can fail
The "adding a new monitored kind" procedure could not fail, so it now carries one: add a GitHub Actions workflow run as a second kind and change nothing in the shared layers. That kind shares the credential and the CLI, so it adds no authentication work, while being a genuinely different subject with different terminal states and an objective that is not
review_ready. It passes only if no shared decision code, no shared result type and no shared protocol changed, and the decision engine's existing tests pass unchanged. If it cannot pass, the prerequisites were wrong and get fixed there -- a branch added for the new kind at that moment is the whole substrate failing quietly.One boundary, stated rather than papered over
The substrate covers loops with an external subject to probe. A conductor patrolling its own session has no subject to fingerprint, no revision that advances and no host to ask for a verdict, so it stays on the timer path. That is not a gap to close later.
Testing
scripts/docs-lint.shpasses (exit 0, "All documentation checks passed").scripts/check_brand_name.pypasses on the new file. Documentation only; no source file is touched.Rebased onto
53987e756, and the spec's verified-against commit updated to match. Every status-table row was re-checked against that commit rather than carried forward: all seven rows and both numeric claims still hold.probes/__init__.pystill maps one kind in anifwith its docstring still recording the deferred registry;monitoring/decision.pyis still exactly 119 lines with zero GitHub references and zero IO;irq.pyis still 1127 lines holding its own decision logic and unversioned state, which is why its mechanisms are ported rather than its module kept.The four earlier failures were main-owned and are cleared by the rebase rather than by any change here. The blocking one was
test_no_bundled_scripts_are_shipped_here, which assertedbuiltin_skills/security-conductor/scriptsdoes not exist while that directory does. Upstream narrowed that guard rather than dropping it: at53987e756the same class carriestest_no_stub_scripts_are_shipped, which permits the directory and instead asserts that every.pyfile in it is listed inBUNDLED_SCRIPTSand is non-empty. The intent is unchanged -- a present-but-empty script is worse than an absent one -- and only the premise moved, because the old assertion assumed no script had landed yet andscripts/ledger.pysince has. Coverage Gate and PR Readiness were downstream aggregates.The whole file is printable ASCII.
Blocked features
None.
Other suggestions
Three findings a reviewer should weigh.
The lint reports three report-only
dead-identifierfindings forresets_onandbudgets_spent. Those are proposed field names, so their absence from the tree is correct. They are deliberately not baselined: a baseline entry for a name that is about to exist becomes a stale entry someone has to prune.The "rules the engine enforces" section now marks which rules are
targetrather than present, because two of them are enforced nowhere in the engine. The stall streak has no engine state at all -- the only consecutive counter in the package isconsecutive_provider_errors, which counts provider failures. The aggregate-authority rule is enforced only in the prepare-pr status tool viaresolve_readiness_context; the structured provider has no aggregate notion. And the collapse rule is actively contradicted:collapse_supersededin the status tool collapses to the newest attempt per identity, while_normalize_checksgives every check row its own group key and_normalize_checkmapsCANCELLEDto failed, so the provider can wake on a failure that no longer exists. This matters beyond this spec, because it decides whether those rules can be deleted from an agent instruction yet.babysit-pr-watch.mdopens with "two current monitoring modes", which stops being true once the consolidation removes one. Not fixed here.The spec records one deviation rather than resolving it: every wake re-injects into the same session, so its context grows for the life of the watch, while the prevailing pattern elsewhere is a fresh context per wake. Both current implementations share the deviation. It is larger than this consolidation and wants its own proposal; it is written down so the omission is not mistaken for an argument that same-session wakes are correct.