- Status: done
- Date: 2026-06-05
- Specs touched: docs/specs/HEALTH.md (locus-B "Built" note)
Closes #39. Adds the clock-not-synced locus-B detector on the seam #34 built (health-system-report.md): host-agent samples chronyc tracking, the brain reconciles it under the report's time category. The third locus-B detector after storage (boot-pipeline-units.md) and service-down (health-system-report.md), and the first Network-category issue in the registry.
(#39 was labelled blocked on #34, which merged 2026-06-01 — the label was stale per the issue's own "drop the blocked label when it lands" instruction; dropped on pickup.)
- Reporter (
internal/hostagent/clockhealth/): runschronyc tracking, parses Ref time (last source update) and Last offset, and emits one box-wideclock-not-syncedfinding when the last sync is older than 6h OR the offset exceeds 10s (thresholds fixed by spec — TIME.md). A pureparseTracking(first-colon split — chrony keys never contain a colon, so the time's own colons fall in the value) keeps the parse unit-testable;Reporter.Readwraps it with a lazy 5-minute cache so chrony is re-queried at most every 5 min even though the brain polls every 60s.now/runTrackingare injectable. Mirrorsservicehealth. - Wiring (
internal/hostagent/agent.go): a new consumer-sideClockReporterinterface (sameRead() []protocol.Findingshape asServiceReporter, named per the established per-category conventionHealthSource/ServiceReporter) + aTimefield onAgent; thesystemHealthhandler emits thetimecategory only when a reporter is wired (so a nil reporter reads as "not measured", never "healthy").FakeClockReporteradded tofake.gofor tests. - Brain registry (
internal/health/health.go): oneclock-not-syncedDefinition— display Categorynetwork, severitywarning, tier 2, no block flags,ReportCategory: time,Debounce: true. Summary verbatim from TIME.md. No reconcile-loop change:pullSystemHealthalready iterates every reported category, andApplyFindingsclears-absent byReportCategory, so the registration is sufficient. - Real binary (
cmd/host-agent-real/main.go):a.Time = clockhealth.New(). - Tests:
clockhealthparser tests against verbatimchronyc trackingoutput (synced + never-synced/epoch), threshold/boundary/cache/resample/fail-openReadtests; a host-agent handler test (timecategory present when wired, absent when not); a brain reconcile test (TestApplyFindings_ClockNotSyncedDebounces) exercising both states with the 2-bad/1-good debounce.
- HEALTH.md # Detector catalog (
clock-not-syncedrow, locus B):chronyc tracking, raise on >6h-since-sync OR offset >10s, clear when synced and offset <10s, debounce. Realized; the locus-B "Built" note now lists it. - HEALTH.md # Network + TIME.md # Drift monitoring: display category
network, warning, no block flags, tier-2 (admin-driven), exact summary string. Realized. - TIME.md: host-agent (not the containerized brain) owns every
chronycinteraction; 5-min sampling cadence. Realized via the lazy cache.
- No notify-center entry.
clock-not-syncedsurfaces via the health banner (GET /api/v1/health) only — it is intentionally not added to notify'shealthRulesallowlist, matching the reference locus-B detectorservice-down, which also has no rule. Out of scope for #39 (which is detector-only). - 5-min cadence via a lazy cache, not a background sampler. In the pull architecture the brain polls every 60s; the reporter re-runs
chronycat most every 5 min and returns the cache between samples. Trade-off: clear latency after the clock resyncs can be up to ~5 min + one poll — acceptable for a warning. - Real-system verification is the maintainer's lane.
chronycisn't installed on the dev box, so the parser is tested against captured real-format output. Per #39's testing note, parsing livechronyc trackingon a real host / the QEMU medium lane is the confirming step.cmd/host-agent-realalso only compiles withlibpam0g-devpresent (CI / medium lane), not on a bare dev box — the change there is an import + one assignment that satisfiesClockReporterby construction. - "Force sync now" (
chronyc -a makestep) is a sibling, not here. TIME.md's tier-2 action needs its own host-agent action endpoint — explicitly carved out of #39.
- The remaining now-unblocked sibling locus-B detectors follow this exact pattern: #40 reboot-required (file presence; needs a display-category decision — not in the reserved enum) and #38 ram-pressure (PSI; threshold is "tune at first soak", an open design value to settle before building).
- "Force sync now" action endpoint (TIME.md # What stays available) — separate issue.
- Settings → System → Time surface (TIME.md) — consumes this issue + the offset/last-sync display; frontend, not yet built.