7.1 KiB
REPORT — R-55 boot-gate stop-honesty (TASK-E Part 2), 2026-07-21
Versions: v0.157.0 (R-55) + v0.157.1 (.gitignore anchor). Baseline 83f20c8 (v0.156.0), clean tree.
v0.157.0 is BUILT, PUSHED and DEPLOYED to guest 9201.
Scope correction — this part was specified against the wrong repo
TASK-E placed R-55 in felhom-agent (Parts 1–2 → felhom-agent) and separately forbade controller
changes ("felhom-controller untouched", "no controller changes at all"), with a supervised-lite leg
described as an agent binary deploy to felhom-pve.
R-55 is not in the agent. It is felhom-controller/controller/internal/web/intermediary.go —
the in-guest boot bind gate. The agent's only related code is host-side ReassertGuestBinds, which
re-adds pct set -mpN binds and never touches apps. The GO ruling and the scope fence therefore
contradicted each other; the fence was evidently written believing the gate lived in the agent.
Raised as a STOP rather than resolved unilaterally, because it changes which repo ships and which artifact deploys. Operator ruling 2026-07-21: implement in the controller AND deploy to 9201. Done.
The defect
shouldRecreateOnBoot keyed on Deployed && HDD_PATH && drive-present. Deployed is a
deploy-lifecycle flag that stays true across a Stop, so the gate could not tell "the guest went down
under this app" from "the customer switched this off" — and resurrected both. Proven live by STOP-1's
R-52 leg, which was designed to prove the opposite: immich, stopped from the UI seconds earlier, came
back running after pct reboot 9201.
The operator's rationale for ruling this GO is worth keeping attached to the fix: an unconditional recreate can drown the node when apps were deliberately stopped to free resources for others. This is not only about honesty; it is about not fighting the customer's own capacity management.
Spike-lite: a truthful source already existed — nothing was invented
The task required stopping rather than inventing state if no honest running-at-shutdown signal existed. It does exist, and R-52 already trusts it:
AppConfigcarries no run-state.Deployedis lifecycle, not intent.Stack.Containerscomes fromdocker ps -a(soExitedcontainers count), and docker's own on-disk container records survive a reboot.- A UI Stop is
compose down, which removes the containers. Nothing else leaves a deployed app at zero containers.
That is exactly bootrecon.isBootOrphan's existing-Exited vs absent distinction. The gate now
additionally requires len(Stack.Containers) > 0.
What deliberately did NOT change: container STATE is still not a filter. That half of the original
design is load-bearing — a State != stopped filter misses an app docker has not auto-restarted yet,
or one stuck Exited on a create-time bind failure with RestartCount=0, which is the bug the boot-id
path exists to fix. state and hasContainers are different questions ("is it up right now" vs "does
docker still have records of it") and only the second survives a reboot as a statement of intent.
TestShouldRecreateOnBoot now pins both axes at once; they pull in opposite directions, which is the
entire difficulty of this gate.
Ordering trap, found and handled: the evidence is sampled into the bootStack snapshot before
any recreate runs, because recreate itself calls StopStack (compose down) and destroys it.
No regression to the drive-absent gate: apps it stopped are also at zero containers, so this path
now skips them — correctly. They are recorded in StoragePath.StoppedStacks and restarted by
ReconcileDriveGates' Return branch on the same driveGateLoop tick. Checked, not assumed.
Honoured Stops are observable. leftStopped is counted and logged separately from skipped, at
INFO. Conflating them would fire a WARN about a missing drive bind for an app behaving exactly as
asked — and a silent correct path is how an inert seam hides.
No new privileged verb, as required: StopStack/StartStack are plain docker compose execs in
the controller's own container, and the boot path's only agent calls are read-only (agent.Disks).
The agent sudoers/wrapper set is untouched — nothing in it relates to app start/stop.
Red-proof — RUN
Dropped hasContainers from the predicate:
--- FAIL: TestShouldRecreateOnBoot
R-55 customer-stopped drive app (zero containers): shouldRecreateOnBoot = true, want false
R-55 gate-stopped app (zero containers; Return branch owns it): shouldRecreateOnBoot = true, want false
--- FAIL: TestRecreateDriveBackedApps_HonoursCustomerStop
recreated=[romm immich], want exactly [romm] — a customer-stopped app must never be restarted
recreated=[romm immich] is the live defect by name. Fix restored → green; git diff clean.
Gates and deploy verification
go build ./... && go vet ./... && go test ./... — green, whole module.
gitea.dooplex.hu/admin/felhom-controller:0.157.0 | Up (healthy)
Not an inert seam — the new code is verified present in the running binary, not just the image:
grep -a -c "stopped them on purpose" /usr/local/bin/felhom-controller → 1 inside the live
container. This check exists because three inert-seam defects shipped fully-green in three days.
The live reboot leg is NOT done, and that is a deliberate stop
Proving R-55 end to end needs a drive-backed app stopped from the UI followed by pct reboot 9201.
9201 has two drive-backed apps (calibre-web, immich) on hdd_1, so the fixture is available — but the
demo box is at a remote site until ~2026-08-02, and rebooting it unprompted is not a call CC
should make (the 2026-07-20 dhclient incident cost 1h15m from a smaller action). One operator-present
STOP away. Until then R-55 is shipped and wired, not proven live; the capability-map row says so
in those words.
v0.157.1 — .gitignore anchor (TASK-E Part 5)
controller/.gitignore carried a bare controller, which git matches against directories too, so
it also matched cmd/controller/. Two opposite failure modes, both manufacturing inert seams:
ripgrep silently skipped cmd/controller/main.go (a search for a setter's caller returns nothing and
reads as "unused"), and new files there needed git add -f or were never committed.
Anchored to /controller + /controller.exe. Verified both directions: git check-ignore no
longer matches cmd/controller/main.go and rg now finds it; a built binary at the module root is
still ignored. git ls-files confirms nothing had been hiding untracked behind the old rule.
Observations
TestShouldRecreateOnBoot's old comment argued explicitly that a state filter would be a regression — correct, and still true. The comment is now expanded to say whyhasContainersis a different axis, so the next reader does not "fix" it back.- The gate calling
StopStackbeforeStartStackon every recreate is what erases the container evidence, and is also why R-52's boot-orphan sweep only ever sees non-drive-backed apps. R-52's practical scope on a real box remains narrower than its brief assumed; unchanged by this fix.