homelab-codex-ws/services/control-plane
oskar 71a7af5b3f fix(control-plane): unwedge incidents that never get service_healthy
_resolve_incident() only ever fires from process_event() on a
service_healthy/service_recovered event. A service that is removed,
renamed, or was only ever a one-off test never emits that event again,
so its incident stays "active" in world/incidents.json forever — this
is what left 5 incidents wedged on VPS until a manual on-node edit
during the 2026-08-26 recon session (docs/sessions/2026-08-26.md).

Two independent unwedging mechanisms, both in observer._prune_stale_world
(runs every cycle, so no new event is required to trigger either):

(a) Time-based fallback: any active incident with last_occurrence older
    than INCIDENT_STALE_RESOLVE_SECS (env, default 24h) auto-resolves
    with resolved_reason="auto_stale_no_events_24h". Unlike the existing
    orphan case (Case 2, 5-min guard, only unlinked incidents), this
    also clears a service's lingering incident_id link — that link is
    exactly what a decommissioned service's incident never gets a
    chance to clear via the normal event path.

(b) Manual path: an operator touches
    world/resolve-requests/<incident-id>; the observer consumes the
    flag file each cycle, force-resolves with resolved_reason=
    "manual_operator", and always removes the flag (even for an
    unknown/already-resolved id) so a mistyped flag can't sit forever
    looking unprocessed.

    Chose a flag file over adding a mutation endpoint to operator_ui.py:
    /action/mutate only knows actions/<status>/<id>.json, there is no
    incidents equivalent, and world/incidents.json is exclusively
    observer-owned (rewritten wholesale every cycle by _save_world) —
    a second writer (the HTTP handler thread) would race the observer's
    own writes. A flag file needs no new HTTP surface and reuses the
    same "operator drops a file, the owning process consumes it"
    pattern the actions pending/approved queue already uses. Smaller
    diff, no new attack surface on a server with no auth on writes.

Tests added to test_incident_lifecycle.py: stale-resolve past the
threshold (service still linked), negative case (fresh active incident
stays active), configurable threshold, manual-flag resolve + flag
removal, flag for an unknown incident, flag for an already-resolved
incident. Full control-plane suite: 179 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017WDKj5LRY8vdQMx57dfNnu
2026-08-26 21:05:38 +02:00
..
src fix(dispatch): inbox 0o775 + rsync rc=23 przestaje byc cichy 2026-08-06 13:42:47 +02:00
tests fix(control-plane): unwedge incidents that never get service_healthy 2026-08-26 21:05:38 +02:00
deploy-local.sh fix(deploy): skip sudo chown/chmod when /opt/homelab ownership is already correct 2026-06-03 15:44:44 +02:00
docker-compose.yml fix(kb): 13 pozostalych odwolan do sciezek sprzed migracji 2026-08-04 17:02:12 +02:00
Dockerfile fix(control-plane): run executor as uid 1000 with docker group access 2026-06-03 18:19:58 +02:00
env.example fix(security): control-plane UI bind na Tailscale IP — 18180 z /action/mutate byl publicznie dostepny bez auth na VPS 2026-07-22 17:29:11 +02:00
pyproject.toml feat(control-plane): route ha-diag-agent events through supervisor 2026-05-29 15:59:23 +02:00
README.md fix(kb): README-wskazniki dla services i hosts + wyjatek ken-legacy 2026-08-04 16:58:46 +02:00

control-plane

Observer + Supervisor + Executor + Operator UI na VPS — synteza world state z eventow i dyspozycja akcji.

Dokumentacja: kb/services/control-plane.md