Fixes the "dead node shown NOMINAL" silent outage: node status was set only by events and never expired, so a node that crashed/lost connectivity stayed "online" forever (chelsty-infra was online for 16d, piha ~6d). The only thing that flipped status to offline was a node_offline event, which an unreachable node can never emit. Now node status is derived from freshness (now - last_seen), recomputed every observer cycle (incl. cycles with no new events): - always-on: fresh <=180s, stale 180-600s, dead >600s (3x the 60s heartbeat) - remote/LTE (chelsty-*): fresh <=900s, stale 900-3600s, dead >3600s Thresholds + tier logic live in ONE shared helper, services/control-plane/src/ liveness.py, imported by the observer and both operator UIs (bind-mounted into the agent-system webui image). No 3x copy. Transitions are not silent: the observer emits node_stale / node_offline / node_online (recovery) events tagged source=observer (skipped on re-ingest so they never reset last_seen), routed by the supervisor to alert_only actions. Read-time safety net: both UIs recompute liveness from last_seen at request time, so a stalled observer still surfaces dead nodes. Services inherit their node's liveness (cascade, variant B) without mutating services.json. Replaces the earlier binary NODE_OFFLINE_TTL_SECS flip. Tests: liveness unit tests, observer 3-state + transitions/recovery/baseline + self-event skip, operator_ui read-time net + cascade, supervisor node-event routing. 89 passed. docker compose config valid for both stacks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.6 KiB
Observer Runtime
The Observer Runtime is a lightweight agent responsible for synthesizing the operational world state of the homelab from raw events, logs, and state files.
Architecture
The observer follows a filesystem-first approach, consuming append-only events and generating a normalized world model. It is designed to be idempotent, resumable, and resilient to intermittent node connectivity.
Inputs
/opt/homelab/events/: Normalized JSON events (one.jsonfile per event, organized by date and node)./opt/homelab/state/observer_checkpoint.json: Per-node checkpoint dict (see below).- Repository Inventory:
inventory/topology.yamlandhosts/*/services.yaml.
World Model Output
Generated under /opt/homelab/world/:
nodes.json: Current node availability, roles, disk/memory pressure, last seen timestamps. Dict keyed by node name.services.json: Service health status and links to active incidents. Dict keyed by"node/service".deployments.json: Tracking of active and historical deployment runs bycorrelation_id.incidents.json: Correlated operational issues, including repeat failures and resolution status.runtime-summary.json: High-level overview for dashboards and planner agents.
Checkpoint Format
The observer tracks per-node progress to avoid silently skipping event directories:
{
"node_checkpoints": {
"vps": "/opt/homelab/events/2026-05-27/vps/evt-vps-1234.json",
"piha": "/opt/homelab/events/2026-05-27/piha/evt-piha-5678.json",
"chelsty-infra": "/opt/homelab/events/2026-05-27/chelsty-infra/evt-chelsty-infra-9012.json"
}
}
A single global checkpoint (last_processed_file) was replaced with this per-node dict because the old approach silently skipped any node directory that sorts alphabetically before the last-seen node (e.g. piha/ would be skipped when the checkpoint pointed to vps/).
Reset: Delete /opt/homelab/state/observer_checkpoint.json. The observer will reprocess all events and rebuild world state from scratch.
Event Types
Negative events (create/escalate incidents)
service_unhealthy,healthcheck_failed— open or increment an active incidentdeployment_failed— record failure in deployments.json
Positive events (resolve state)
service_healthy— marks service status ashealthyand resolves any active incident for that serviceservice_recovered— alias, same effectdeployment_completed— marks deployment as completed
Node events
node_online,node_offline— update node status in nodes.jsondisk_pressure_*— setdisk_pressurefield on the node record
Node Liveness (TTL)
Node status is derived from freshness, not from the last status event.
Every reconcile cycle (_prune_stale_world, runs even with no new events) the
observer computes now - last_seen per node and classifies it:
| tier | age (always-on) | age (remote / LTE) | status written | panel health |
|---|---|---|---|---|
| fresh | ≤ 180 s | ≤ 900 s | online |
nominal |
| stale | 180–600 s | 900–3600 s | stale |
degraded |
| dead | > 600 s | > 3600 s | offline |
error |
| unknown | last_seen missing |
— | (unchanged) | (unchanged) |
Thresholds (tied to the 60 s node-agent heartbeat: fresh = 3× interval) live in
one place: services/control-plane/src/liveness.py, imported by the observer
and both operator UIs (compute_liveness / ttls_for / node_health). Remote
nodes (topology role remote, or chelsty-*) use the wider TTLs above.
Override via env: LIVENESS_TTL_FRESH, LIVENESS_TTL_DEAD,
LIVENESS_REMOTE_TTL_FRESH, LIVENESS_REMOTE_TTL_DEAD.
This fixes the "dead node shown NOMINAL" silent outage: previously status
stayed online forever because the only thing that flipped it to offline was a
node_offline event, which a crashed/partitioned node can never emit.
Transitions are not silent. On crossing a boundary the observer writes an
event (node_stale, node_offline, or node_online on recovery) tagged
source: "observer" with the affected node in both node and
payload.affected_node. These are skipped on re-ingest (so they never reset
last_seen) and routed by the supervisor to alert_only actions (Telegram).
Read-time safety net: both operator UIs recompute liveness from last_seen
at request time using the same helper, so even a stalled observer (frozen
nodes.json) still surfaces a dead node. Services inherit their node's liveness
(a service on a dead/stale node is never shown nominal) — computed read-time,
services.json is not mutated.
Incident Lifecycle
- Detection: A
service_unhealthyorhealthcheck_failedevent creates or increments an active incident. - Correlation: Multiple failure events for the same
node/serviceare collapsed into one incident, incrementingoccurrence_count. - Resolution: A
service_healthyorservice_recoveredevent resolves any active incident for that service, settingstatus: resolvedandresolved_at. - Expiry: Resolved incidents older than 7 days are pruned from world state by
_prune_stale_world().
Example Incident JSON
{
"inc-1715518800-vps-observer": {
"id": "inc-1715518800-vps-observer",
"node": "vps",
"service": "observer",
"status": "resolved",
"severity": "error",
"started_at": 1715518800.0,
"last_occurrence": 1715518860.0,
"occurrence_count": 2,
"trigger_type": "containers_not_running",
"resolved_at": 1715519100.0
}
}
World State Pruning
_prune_stale_world() runs every reconcile cycle and removes:
- Stale nodes — nodes not present in
inventory/topology.yaml(e.g. ghost nodes created whenNODE_NAMEwas unset and fell back to the container's 12-char hex ID). - Services of stale nodes — all
node/servicekeys whose node was pruned. - Ghost service keys — service keys whose service-name portion matches the pattern
<12hexchars>_<name>(Docker internal stale-state artifacts, created when node-agent usedc.nameinstead of the compose label). - Expired incidents — resolved incidents older than 7 days.
Runtime Behavior
Idempotency
The observer processes events in order. Deleting the checkpoint and restarting replays all events and produces the same world state.
Deployment Tracking
Deployments are tracked via correlation_id. The observer synthesizes the start, end, and status of each deployment run from events.
Topology Filtering
Events from nodes not listed in inventory/topology.yaml are discarded during pruning. This prevents transient bootstrap noise from polluting world state.