- Status: done
- Date: 2026-06-03
- Specs touched: none — realizes the already-locked 2026-06-02 decision (
DECISIONS.md"app-unresponsive un-deferred: opt-in health_probe, probed through Caddy",APP_MANIFEST.md# B,HEALTH.md# Detector catalog locus C,THREAT_MODEL.md# B2). No divergence.
Closes issue #54. The 2026-06-02 spec drop un-deferred app-unresponsive and pinned the health_probe manifest field, the locus-C detector, and the probe-through-Caddy security call — but no code implemented them: container-restart-loop was the only app-liveness signal, and a container can be running and accepting TCP yet not answering HTTP coherently. This adds the optional health_probe field and the app-unresponsive detector that consumes it. It is the "up but not responding" half of app liveness.
internal/manifest— new optionalHealthProbefield (health_probe). CustomUnmarshalYAMLaccepts both the shorthand string (health_probe: /healthz→{path: /healthz}) and the full mapping (path/healthy_status/start_period);start_periodis a Go duration string on the wire. A matchingMarshalYAMLemits the mapping form (start_period back to a duration string) so a parsed manifest survives the marshal→parse round-trip thatwriteInstanceDir/loadInstanceManifestdoes on the per-instancemanifest.yml. Validation rejects an empty/relative path, a negativestart_period, and out-of-rangehealthy_statuscodes, and normalizes thestart_perioddefault (60s) in place. Absent ⇒nil⇒ the app is never probed.internal/health— registered theapp-unresponsivedefinition (Category version, warning, Tier 2, blocks nothing), mirroringcontainer-restart-loop. Brain-owned (noReportCategory, so a host-report poll never clears it).Debounceleft false on purpose: the detector callsRaise/Cleardirectly (not viaApplyFindings), so the flag would be inert — the 2-bad/1-good debounce lives in the detector, likecontainer-restart-loop's window does.internal/lifecycle— addedManagedContainers(ctx) ([]ManagedContainer, …)to the Docker seam: onedocker inspectover managed containers returning per-container{instance_id, service, running, StartedAt}, for the steady-running + start-period gate. AddedManager.InstanceManifest(id)— a thin exported wrapper over the on-diskloadInstanceManifestso cmd/brain doesn't duplicate the instance-dir layout.cmd/brain— newappProbeDetector(locus C). On each tick it lists instances, loads each manifest, and for every instance that declares ahealth_probe, isn't already flaggedcontainer-restart-loop, has itsmain_servicecontainer running, and is past the start-period grace, it GETs the probe path through Caddy — a request to Caddy's listen address withHost: <route host>, never dialing the container (THREAT_MODEL.md# B2). Healthy = status inhealthy_status(default any< 500); a 5xx / timeout / connection failure (Caddy's 502 for a dead upstream) is unhealthy. Reconciles the per-instanceapp-unresponsiveissue: raise on the 2nd consecutive failed probe (or immediately while already active), clear on one good probe or loss of eligibility (stopped / now crash-looping / uninstalled). Audit + notify wiring mirrorscontainer-restart-loop.- Shared poll goroutine — per the issue ("reuse the timer, do not add a parallel goroutine"), the restart-loop and probe detectors now run from one goroutine (
appRuntimeHealthLoop) on the existing 60s health-poll cadence, restart-loop first so the probe can defer to a freshly-raisedcontainer-restart-loopand not double-banner a crash-looping app. This replacedrestartLoopDetector.run(its sole caller), which was removed as the orphan of this change.
APP_MANIFEST.md# B —health_probeis opt-in and malmo-executed (not DockerHEALTHCHECK), shorthand-expanding, default healthy< 500, with astart_periodwarm-up grace. Door-2 synthetic manifests omit it (they build the struct directly withHealthProbenil).HEALTH.md# Detector catalog (locus C) — measurement is "HTTP GET through Caddy (Host: <slug>, path =health_probe.path); status vshealthy_status", cadence 60s, raise on 2 consecutive bad after the grace, clear on 1 good. Anti-flap guards (start-period grace; probe only steady-running) are implemented exactly.THREAT_MODEL.md# B2 /DECISIONS.md2026-06-02 — probing through Caddy (not dialing the container) keeps the control plane off every app-reachable Docker network. The detector only ever talks to Caddy's listener.CLAUDE.md# Go discipline — consumer-side narrow interfaces in cmd/brain (managedContainerReader,instanceLister,instanceManifestLoader);log/slogonly; transitions audited per issue (health.issue.*).
- No real Docker/Caddy end-to-end. The api harness builds with
life=nilandManagedContainersshells out to Docker, so the unit tests assert at hermetic seams: a stubhttp.RoundTripperkeyed on the requestHostdrives healthy/unhealthy/connection-failure probes, and fakes supply the instance list, manifests, and container states. The "routes through Caddy by Host" test pins that the probe hits the Caddy listener base URL withHost: <route host>and the manifest path — but no test exercises a real Caddy round-trip. Thedocker inspectformat string inManagedContainersis not unit-tested (no Docker in the dev box); it mirrors the verifiedRestartCountsshape. - Bell notification is a no-op until allowlisted. Like
container-restart-loop,app-unresponsive(warning, per-app) isn't ininternal/notify'shealthRulesallowlist, soemitHealthNotificationsdispatches and notify drops it. It still raises the dashboard health issue + audit record. Wiring a bell rule is a separate notify change, deliberately out of scope. - Probe dial address. The probe targets the Caddy listen address
EnsureServerwas given (MALMO_CADDY_LISTEN, default:80→http://127.0.0.1:80), overridable viaMALMO_CADDY_PROBE_URLfor a containerized brain reaching a Caddy container by service name. v1/.localis plain HTTP, so the HTTP listener +Hostheader is the path; HTTPS/SNI in secure-URL mode is deferred (default client follows redirects, untested here). - Open sub-decisions (recorded per the issue): probe timeout = 5s (matches the brain's other host-call timeouts, well under the 60s tick; tune at first soak); start-period default = 60s; probes are sequential per tick (fine for v1 catalog sizes).
go test ./cmd/brain/ ./internal/manifest/ ./internal/health/ ./internal/lifecycle/ green, plus the full non-PAM suite (go test over ./cmd/brain/... ./internal/... minus the PAM-cgo target). go vet + gofmt clean on the touched packages. Manifest tests cover shorthand→object expansion, full-form parse, bad path/status/duration rejection, and the marshal→parse round-trip. Detector tests cover: 2-bad-sample debounce raise, 1-good-sample clear, default <500 (404 doesn't raise), narrowed healthy_status, start-period suppression then raise, opt-out (no probe → never probed), crash-looper deferral to container-restart-loop, connection-failure-is-unhealthy, not-running skip, clear-on-uninstall, Host-routing wiring, and a probeHealthy classification table. (go vet ./... and make test-nopam both trip on a pre-existing root-owned dev/test-qemu/mkosi.tools/boot/loader permission error unrelated to this change; ran the package list directly instead. The cmd/host-agent PAM-cgo build needs libpam0g-dev, absent in this dev box — pre-existing, unrelated.)
- Allowlist
app-unresponsivefor the bell if product wants it surfaced beyond the health banner (ainternal/notifyhealthRulesentry; same pending step ascontainer-restart-loop/version-mismatch). - Real Caddy round-trip coverage once a lane can stand up Caddy + a managed app (the medium QEMU lane) — assert a dead upstream surfaces as 502 →
app-unresponsive. health_probein the Door-2 form is explicitly out of v1 (#57's "Edit as YAML" escape hatch authors it by hand); no form control planned.