feat(canary): fail loud when the collector is blind on a provably live fleet (#1813) - #1879
Conversation
…e fleet (#1813) #1540 repointed the canary's SQL-tier collectors onto the configured engine so they read the live DB on PostgreSQL. It did not change the failure *shape*: a collector reading an empty or unreachable source returns zero rows, and zero rows is indistinguishable from a genuinely clean fleet. Verified against the real collect_snapshot() over a diverged backend — a two-agent fleet with a running execution yields known_agents=set(), sources_unavailable=[], and zero violations across every invariant. A silent all-clear while completely blind. Prior coverage was worse than none: L-03 fires in that state only when an execution happens to hold a Redis slot (an idle fleet holds none), and when it does it reports a "ghost agent absent from agent_ownership" — sending the on-call after a delete-cascade bug instead of a blind detector. Adds H-01, the harness's first self-check, under a new `H-` (harness health) id family: every other invariant means "the system is broken", H-01 means "the observer is blind", and an H-01 violation invalidates every other green in that cycle. Filing it under `G-` would have made a detector outage read as a platform defect in the violations feed. Evidence must not be circular, so it comes from sources that never touch the SQL tier under test: - Docker container presence, read from the container LIST before any exec. Snapshot gains `docker_agent_names` rather than reusing `zombie_counts`, which is keyed by exec_run success and silently thins on a degraded container. - Redis slot keys, corroborating only — slot keys exist solely while an execution holds a slot, so an idle fleet legitimately has none. Outcomes: roster_read_failed / roster_empty_contradicted (critical) and roster_empty_unverifiable (major) when the evidence source is itself unreachable — the "a dead smoke detector should chirp" branch. An unreadable or unwritable marker fires unconfirmed rather than skipping; a guard that cannot self-check must say so, or it reproduces the silent-green failure it exists to prevent. Confirmation is on elapsed wall-clock, not on "a second cycle". Prod runs uvicorn --workers 2 and canary_service holds only a per-process asyncio.Lock with no leader lease, so two loops share the marker and a cycle-count rule would let worker B confirm worker A seconds later — collapsing the gate to nothing inside the container-teardown window it exists to ride out. Scoped to the roster read alone: on a live-but-quiet fleet terminal_rows, enabled_schedules, orphan_refs and terminal_exec_statuses are all legitimately empty, so a general "any SQL collector reads zero" rule would false-alarm on every idle install. Residuals, documented rather than papered over: an entirely stopped fleet has no containers and no slots, so it can only reach roster_empty_unverifiable; partial blindness (a roster returning 1 of 20) is out of scope, since a count comparison would false-fire on legitimate create/stop races. Verification: 159 canary tests (randomized + ordered), 5525 tests/unit, and both real-PostgreSQL arms including a new firing-path test that reproduces the #1540 shape on psycopg2 and asserts every other invariant is vacuously green there. Mutating the guard to always-green fails 14 tests. Fixes #1813
|
Resolve by running |
# Conflicts: # docs/memory/learnings.md
…cles" The /review fix moved the confirmation gate from a cycle count to CONFIRMATION_MIN_SECONDS elapsed — because prod runs --workers 2 and canary_service holds no leader lease, so both loops share canary:h01:suspect_since and a cycle-count rule collapses to zero delay. The code changed; five prose sites did not, and still described the mechanism as "two-cycle confirmed" (architecture.md additionally claimed "~10 min to alarm instead of ~5", which only follows from a cycle rule). Per this PR's own learnings entry, prose stating a different invariant than the code enforces is a review target in its own right. No behaviour change — comments and docs only.
Collides with #1880 (5658e67), which reworked the same canary alert surfaces this branch adds H-01 to. Resolutions: * services/canary_alerts.py — both sides appended id branches to _render_message. Kept both. #1880's comment block ("the five branches below") stays glued to its five branches; H-01 follows, ahead of the count-only fallback. * requirements/infrastructure.md — took dev's paragraph wholesale. Ours documented the E-03/E-04/E-06/G-03/G-04 alert gap as a known residual; #1880 closed it, so our text was not just redundant but wrong, and it named 2-3 surfaces where there are now 4. * feature-flows.md — changelog rows, both dated 2026-07-30. Kept both, #1813 above #1880 (newest-first, ours lands later). Semantic follow-through — #1880 did not just move code, it made all four per-invariant surfaces mandatory and added a bidirectional AST guard (tests/unit/test_1880_canary_alert_parity.py). H-01 shipped here with three of the four, so a textual-only resolution merges green and fails CI: * added the missing _render_forensic branch for H-01; * added H-01 to _OBSERVED_STATE in test_1880_canary_alert_rendering.py, required by test_fixtures_cover_the_registry; * _render_message's H-01 branch used .get(k, default), which does not fire on a key present with value None — caught by test_null_identity_fields_never_raise, now coerced via _mrkdwn_safe. It tests `is None`, so known_agent_count=0 still renders "0", which is the point of this alarm rather than a value to swallow as "?". Verified: tests/unit/test_1880_canary_alert_{parity,rendering}.py 111 passed; tests/test_canary_invariants.py 159 passed, 2 skipped; full tests/unit/ 5850 passed, 15 skipped, 1 xfailed, 0 failed.
…il-loud # Conflicts: # docs/memory/feature-flows.md
…il-loud # Conflicts: # docs/memory/feature-flows.md
dolho
left a comment
There was a problem hiding this comment.
Review — H-01 collector blindness
Strong direction, and the framing is right: a detector that goes quiet is worse than no detector, and the H- family separation from G- is the correct call. The reason-code strings look fine to harden as a contract with trinity-enterprise#202.
Four things before this lands, one of them substantive.
1. The roster_read_failed arm is mostly unreachable, and its payload misreports the evidence sources
Two separate problems, same arm.
(a) The snapshot early-returns before the evidence collectors run. snapshot.py:
try:
agent_rows = _collect_known_agents()
except Exception as exc:
snap.sources_unavailable.append(f"sqlite.agent_ownership: {exc}")
return snap # <-- docker + redis collectors never runSo on the one path where roster_failed is True, docker_agent_names and orphan_redis_slots are always empty and sources_unavailable carries no docker/redis entry. _violation then reports docker_available: True, redis_available: True, evidence_agent_count: 0, and the Slack forensic block renders:
*Sources:* docker=up · redis=up
*Roster vs evidence:* 0 vs 0 agent(s)
For the one alarm whose stated job is legibility, that reads as "everything else is fine and the fleet is empty" when in fact neither source was consulted. Suggest a tri-state — docker_available: None / "not read" when the collector never ran (a collectors_ran set on the Snapshot, or just move the Docker collect above the roster read since it has no dependency on it).
(b) A whole-DB outage never reaches the check at all. canary_service._run_cycle_inner does an unguarded DB read before collecting the snapshot:
previous_latest = db.get_latest_canary_violation_per_invariant() # ~line 195
...
snapshot = await asyncio.to_thread(collect_snapshot)If the database is down, that raises, the loop logs canary cycle raised; will retry next interval, and H-01 is never executed. So roster_read_failed covers only a failure confined to the agent_ownership query, not the DB-down case the docstring's "raised → definitive → critical" row implies. Either wrap that read (fail-open to {} so the cycle still runs and H-01 can fire), or say plainly in the docstring which failures this arm can and cannot see. As written the module promises coverage it does not have — the same shape of problem it exists to fix.
2. Redis-only evidence can page critical on an L-03 condition
evidence = set(snapshot.docker_agent_names) | set(snapshot.orphan_redis_slots)
...
elif evidence:
reason, severity = REASON_CONTRADICTED, SEVERITY_CONFIRMEDorphan_redis_slots is by definition slot keys whose agent is absent from agent_ownership — i.e. the leaked-slot state L-03 exists to report. On a genuinely empty fleet holding one leaked slot key, Docker evidence is empty and H-01 fires roster_empty_contradicted / critical: a correct roster, an unrelated Redis leak, and a critical page saying the harness is blind.
The docstring already says Redis is "corroborating evidence, never the sole basis for the fleet is empty" — but the code makes it a sufficient basis for the contradiction, which is the direction that pages. Suggest: Docker evidence required for SEVERITY_CONFIRMED; redis-only → roster_empty_unverifiable / major.
3. Doc/code mismatch on the confirmation gate
The PR table says a raised roster read is "not needed — definitive", but every arm falls through to the CONFIRMATION_MIN_SECONDS marker gate, so roster_read_failed also waits 60s. Either exempt it (it has no delete-race to ride out — the race only exists for the empty-roster case) or correct the table.
4. Marker has no TTL and clears best-effort
_clear_marker swallows failures and the key has no expiry, so a persistently failing DEL leaves it armed forever — and POST /api/canary/run-cycle with an invariant_ids filter that excludes H-01 means nothing clears it either. A TTL of a few cycles bounds the staleness without weakening the gate (the marker is rewritten on every suspicious cycle anyway).
Verified fine
sqlite.agent_ownershipprefix matches the collector's literal label.slot_serviceclient isdecode_responses=True, so_to_utc(blind_since)gets astr— no bytes/TypeErrorhole.- All four #1880 alert surfaces present (name, runbook,
_render_message,_render_forensic), so the parity test will pass. - Recording
namesbeforeexec_runis the right call and the reason is worth the comment it got.
Merge order
This conflicts with #2022 (both rewrite _collect_zombie_counts's return dict and the Snapshot zombie fields) and with #1997 (canary_alerts). Worth deciding an order across the three and rebasing rather than resolving three-way at merge time.
…mmetric ladder (#1813) Four findings from review, all real. 1a. The `roster_read_failed` arm reported evidence it never gathered. `collect_snapshot` returns early when the roster read raises, so Docker and Redis never ran on the one arm that most needs independent evidence — yet the payload said `docker_available: True`, `redis_available: True`, `evidence_agent_count: 0`, rendering as "docker=up · redis=up … 0 vs 0 agent(s)". On the alarm whose stated job is legibility, that reads as "everything else is fine and the fleet is empty". Two changes. Docker now collects FIRST — it never depended on the roster — so `roster_read_failed` carries real container evidence. And `Snapshot.collectors_ran` makes availability tri-state: `sources_unavailable` records nothing on success AND nothing when skipped, so a two-state test structurally cannot tell them apart. `None` renders as `not read`. 1b. A whole-DB outage never reached the check. `_run_cycle_inner` read `get_latest_canary_violation_per_invariant()` before the snapshot, unguarded, so a DB-down cycle raised out of the loop and H-01 — whose entire job is to announce that the harness cannot see the fleet — never executed. That read is now fail-open. On its own that would swap one defect for another: an empty `previous_latest` makes every violation look like a fresh flip, so a multi-hour outage would alert every 5 minutes. Transition detection therefore falls back to `canary:last_cycle_red`, a Redis-held record of the previous cycle's red set — a separate failure domain from the DB — so a persistent outage still chirps once. 2. Redis-only evidence could page critical on an L-03 condition. `orphan_redis_slots` is by definition slot keys whose agent is ABSENT from `agent_ownership` — exactly what L-03 reports. An empty fleet holding one leaked key fired `roster_empty_contradicted`/critical: a correct roster, an unrelated Redis leak, and a critical page claiming the harness is blind. Docker evidence is now required for `SEVERITY_CONFIRMED`; Redis-only lands on `roster_empty_unverifiable`/major. The names still ride in `evidence_sample` — demoted, not discarded. 3. Doc/code mismatch on the confirmation gate. The code was right and the docstring wrong: a raised roster read is very often a momentary DB blip (connection reset, PG restart, pool exhaustion), and paging critical on one of those is how a safety net gets muted. Corrected the table and said why the gate covers a different transient on that arm. 4. The marker had no TTL. `_clear_marker` swallows failures and an `invariant_ids`-filtered `run-cycle` never reaches it, so an orphaned marker stayed armed forever and the next genuine episode would confirm on its first cycle. 24h TTL, refreshed every suspicious cycle so it is an idle timeout rather than an absolute lifetime — a long episode must not silently re-arm and re-alert. New tests go in `tests/unit/` (26 of them) because `tests/test_canary_invariants.py` is executed by no workflow — filed as #2037. Each pins a finding and fails against the pre-review code. The in-place H-01 suite is updated too, including `FakeRedis` gaining real `ex=`/`expire`/`ttl` semantics, since a fake that ignored `ex=` would let finding 4 regress.
|
All four findings addressed in 1a — the
|
…il-loud Resolves the overlap with #2022 (ent#335/336/337), which rewrote `_collect_zombie_counts`'s return contract and the `Snapshot` zombie fields that H-01 also touches. - `Snapshot`: both sides added fields, none conflicting. Kept all three — #1813's `docker_agent_names`, ent#337's `zombie_pids` and `zombie_container_started_at`. - `_collect_zombie_counts`: took #2022's four-key contract (`pids`/`counts`/`started_at`/`unavailable`) and re-added #1813's `names`, making it five. They answer different questions and must not be conflated: `counts`/`pids` are keyed by `exec_run` success (a liveness signal that thins on a degraded container), `names` comes from the container list before any exec (the presence signal H-01 uses as independent proof the fleet is alive). Folded that distinction into the docstring alongside ent#337's. - `collect_snapshot`: #1813 moves this collector to the TOP of the function, so ent#337's two new assignments moved with it. - `architecture.md`: kept #2022's rewritten R-01 row and #1813's new H-01 row. - R-01 docstring: corrected the claim that `snapshot_time` and the docker exec are far apart ("biases the effective dwell slightly LONG"). They are now adjacent, since the Docker collector runs first. Nothing depended on the gap — noting it so nothing starts to. `tests/unit/`: 7801 passed, 18 skipped. Canary + H-01 + R-01 dwell suites: 219 passed, 2 skipped.
|
Rebased on
Ready for another look. |
dolho
left a comment
There was a problem hiding this comment.
Re-review — H-01, after 36428fc8 + e0206dee
All four findings are genuinely fixed, not just answered — I read the code rather than the response. Two new things, both minor; only the first is worth a commit.
Prior findings — verified closed
- 1a evidence — the Docker collector now runs at the top of
collect_snapshotwith its ownfinally: collectors_ran.add(COLLECTOR_DOCKER), sodocker_agent_namesis populated on theroster_read_failedarm.collectors_ranis the right shape: you're correct thatsources_unavailableis byte-identical for "succeeded" and "skipped", so no discipline at the read site could have recovered the third state — the collector has to declare it._availability'sis not Truein the fall-through branch is the load-bearing detail and it's commented as such. - 1b DB outage —
previous_latestis wrapped, and thecanary:last_cycle_redfallback is the part that makes fail-open safe rather than just louder. I checked the thing the fix depends on and the response doesn't mention:insert_canary_violationis already per-violation try/except, so a DB-down cycle still reachesemit_transitioninstead of dying at persist. The fix works end to end. - 2 severity ladder —
docker_evidencesplit out; Redis-only lands onroster_empty_unverifiable/major with the names still inevidence_sample.test_per_agent_exec_failure_still_confirmspins the ordering subtlety (adocker.exec[...]failure can't reach the unverifiable branch because the name was recorded pre-exec). - 3 doc/code mismatch — resolved in the honest direction: the gate now covers every arm and the docs say so, with a momentary-DB-blip rationale that stands on its own.
test_the_docstring_no_longer_claims_an_exempt_armguarding a docstring against drift is a nice touch. - 4 marker TTL — 24h via
ex=on the write, refreshed each suspicious cycle._refresh_marker_ttlmaking it an idle timeout rather than an absolute lifetime is a case I hadn't made and it's the right call.
Also confirmed: rebase is clean (0 behind dev, #1997 and #2022 both landed), the five-key _collect_zombie_counts contract keeps names (presence) distinct from counts/pids (liveness), and the R-01 docstring correction is accurate — snapshot_time and the docker exec are now adjacent.
1. A filtered run-cycle clobbers the Redis red set (new, ~2 lines)
run_invariants(snapshot, ids) returns only the selected ids, so on POST /api/canary/run-cycle {"invariant_ids": [...]} the new write
self._write_prev_cycle_red([i for i, v in results.items() if v])persists that subset as the entire red set. Every other red invariant is dropped. Reproduced:
full cycle → canary:last_cycle_red = ["H-01", "S-01"]
run-cycle(invariant_ids=["S-01"]) → canary:last_cycle_red = ["S-01"]
Consequence lands exactly where the fallback matters: with the DB down, the next background cycle evaluates inv_id not in prev_red, finds H-01 absent, and re-alerts — re-breaking the "a persistent condition chirps once" property this key exists to preserve. And it's reachable through the same invariant_ids path you already identified as the reason the H-01 marker needs a TTL, so the filtered cycle now has two ways to disturb cross-cycle state.
Either skip the write when a filter is in force, or merge instead of replace:
if invariant_ids is None:
self._write_prev_cycle_red([i for i, v in results.items() if v])(_write_prev_cycle_at has the same shape, but it's one timestamp with "when did we last look" semantics and it predates this PR — not asking you to touch it.)
2. A per-agent exec failure renders as a wholesale Docker outage (nit)
_has(snapshot, COLLECTOR_DOCKER) matches docker.exec[<agent>], which is a per-container skip, not a collector outage. On roster_empty_contradicted with one degraded container the forensic block renders:
*Sources:* docker=unavailable · redis=up
*Roster vs evidence:* 0 vs 3 agent(s)
Docker is simultaneously down and reporting three agents. The branch is right and tested; it's the render that misreports, on the one alarm whose stated job is legibility — and docker_available is also the field trinity-enterprise#202 will score. Matching only the wholesale prefixes (docker:, docker.import, docker.list) and letting docker.exec[...] ride as a separate partial-degradation count would keep both surfaces honest.
Nit, no action needed: when the DB recovers mid-episode, previous_latest holds no rows from the outage (the inserts failed too), so the primary rule reads the still-red condition as a fresh flip and chirps once more. Arguably the behaviour you want on recovery.
Verification
tests/unit/test_1813_h01_collector_blindness.py+ bothtest_1880_*— 137 passedtests/test_canary_invariants.py— 149 passed, 2 skipped. The 20 collection errors are a pre-existing localutils.safe_yamlimport artifact, not this branch:origin/devreproduces 19 of the same.- CI green end to end — all six
pytestseeds (base + head),schema-parity, CodeQL,prod-image-smoke, gitleaks, non-root.
Approving on fix 1. The reason codes and the H- family are good to harden as the trinity-enterprise#202 contract.
Summary
H-(harness health) id family — every other invariant means the system is broken; H-01 means the observer is blind, and its violation invalidates every other green in that cycle.Why this wasn't already covered
Verified against the real
collect_snapshot()over a diverged backend — a two-agent fleet with a running execution produces:A silent all-clear while completely blind. Prior partial coverage was worse than none: L-03 fires in this state only when an execution happens to hold a Redis slot (an idle fleet holds none), and when it does it reports
agent_name 'a1' ... absent from agent_ownership— sending the on-call after a delete-cascade ghost-agent bug instead of a blind collector.Design
roster_read_failed· criticalroster_empty_contradicted· criticalroster_empty_unverifiable· majorroster_empty_unverifiable· majorEvery firing arm goes through the confirmation gate,
roster_read_failedincluded — see below.Evidence is deliberately non-circular.
Snapshotgainsdocker_agent_names, read from the container list before anyexec_run— not reused fromzombie_counts, which is keyed by exec success and silently thins on a degraded container.The Docker collector runs BEFORE the roster read (it never depended on it).
collect_snapshotreturns early when the roster read raises, so collecting Docker below it left theroster_read_failedarm — the most severe one — with no evidence at all.Docker and Redis are not interchangeable for severity.
orphan_redis_slotsis by definition slot keys whose agent is absent fromagent_ownership— the leaked-slot state L-03 exists to report. A genuinely empty fleet holding one leaked key would otherwise page critical claiming the harness is blind, over a correct roster and an unrelated Redis leak. Docker evidence is required forcritical; Redis-only lands onroster_empty_unverifiable. The names still ride inevidence_sample— demoted, not discarded.docker_available/redis_availableare tri-state, backed bySnapshot.collectors_ran:Trueran-and-fine,Falseran-and-failed,Nonenever ran.sources_unavailablerecords nothing on success and nothing when a collector is skipped, so a boolean structurally cannot tell those apart — which is how theroster_read_failedarm came to renderdocker=up · redis=up … 0 vs 0 agent(s)on a cycle where neither source had been consulted.Nonerenders asnot read.Confirmation is on elapsed wall-clock, not "a second cycle." Prod runs
uvicorn --workers 2andcanary_serviceholds only a per-processasyncio.Lockwith no leader lease, so two loops share the marker — a cycle-count rule would let worker B confirm worker A seconds later, collapsing the gate to nothing inside the container-teardown window it exists to ride out. (This was caught in/review; captured tolearnings.md.)Fail-loud, never fail-silent. An unreadable or unwritable marker fires unconfirmed rather than skipping — a guard that cannot self-check must say so, or it reproduces the silent-green failure it exists to prevent.
The gate covers every arm, including
roster_read_failed. That arm has no delete race to ride out, but a raised roster read is very often a momentary DB blip (connection reset, PG restart, pool exhaustion), and paging critical on one of those is how a safety net gets muted.The marker carries a 24h TTL, refreshed on every suspicious cycle.
_clear_markeris best-effort and arun-cyclefiltered to otherinvariant_idsnever reaches it, so without an expiry an orphaned marker stays armed forever and the next genuine episode confirms on its first cycle. Refreshing makes it an idle timeout rather than an absolute lifetime, so a long episode cannot silently re-arm and re-alert.A whole-database outage now reaches the check at all.
_run_cycle_innerreaddb.get_latest_canary_violation_per_invariant()before collecting the snapshot, unguarded — so a DB-down cycle raised out of the loop and H-01, whose entire job is to announce that the harness cannot see the fleet, never executed. That read is now fail-open. On its own that would trade silence for spam (an emptyprevious_latestmakes every violation look like a fresh flip, so a multi-hour outage would alert every 5 minutes), so transition detection falls back tocanary:last_cycle_red— a Redis-held record of the previous cycle's red set, in a separate failure domain from the DB.Scoped to the roster read alone. On a live-but-quiet fleet
terminal_rows,enabled_schedules,orphan_refsandterminal_exec_statusesare all legitimately empty, so a general "any SQL collector reads zero" rule would false-alarm on every idle install (AC #3).Acceptance criteria
Snapshot, plus a new real-PG firing testChanges
canary/invariants/h01_collector_blindness.pycanary/snapshot.pydocker_agent_namesfield + populate from the container list pre-execcanary/invariants/__init__.pyservices/canary_alerts.py_render_messagebranch (it carries noagent_name, so the generic fallback would be useless for the one alarm whose job is legibility)tests/test_canary_invariants.pyFakeRedis.delete, which never handled string keys (realDELis type-agnostic)architecture.mdcatalog row +H-family note ·requirements/infrastructure.md§31 Phase 5 · invariant catalog §15 ·learnings.mdNo schema change, no migration, no new endpoint, no config, no feature flag.
Test plan
tests/test_canary_invariants.py— 159 passed, randomized and orderedtests/unit— 5525 passed, exit 0 (4hypothesisfiles excluded; package absent locally, pre-existing)test_pg_h01_fires_on_a_blind_roster, which reproduces the Canary invariant harness is blind on PostgreSQL — reads stale SQLite /data/trinity.db #1540 shape on psycopg2 and asserts every other invariant is vacuously green there while H-01 firescriticalreturn [](the exact silent-green failure mode) fails 14 tests, including the PG firing test. A guard never shown to fire is decorativeKnown residuals (documented, not covered)
roster_empty_unverifiable.Pre-existing gaps found, deliberately not fixed here
--workers 2both loops run every cycle — double-probing the fleet (including R-01'sdocker execinto every agent) and double-writing E-02's Redis state. Out of blast radius; worth its own issue.E-03/E-04/E-06/G-03/G-04have no_INVARIANT_NAMES/_INVARIANT_RUNBOOKSentries and alert as"E-03 fired 1 violation(s)". Noted inrequirements/infrastructure.md.Note for reviewers
The reason-code strings (
roster_read_failed/roster_empty_contradicted/roster_empty_unverifiable), theH-01id, and theH-family are my choice — not specified by the issue. abilityai/trinity-enterprise#202 (benchmark scorer, @vybe) will score against canary output, and its AC requires "any unavailable data source fails the score loud rather than scoring green" — so these names are effectively a contract with it. Worth confirming before they harden.Origin: PR #1690's "Follow-ups (for Andrii to file)" — the silent re-blinding operator alarm — building directly on @AndriiPasternak31's #1540 fix.
Fixes #1813
🤖 Generated with Claude Code