- Status: done
- Date: 2026-06-13
- Specs touched:
APP_LIFECYCLE.md,DECISIONS.md
A stopped → running transition driven by Start re-registered only the Caddy route; it never re-published the app's mDNS name. So an app recovered via Start — after a mid-life host-agent restart dropped its process-local Avahi entry group, or after a prior install/start that failed before publishing — came back reachable by Host-header proxy (curl -H 'Host: jupyter.local' localhost worked) but unresolvable by name (jupyter.local returned nothing in the browser) until the next brain reboot's reconcile pass re-asserted it. Discovered while recovering a failed Jupyter instance (#136/#124). Closes #153.
This is the user-initiated counterpart to the durability the reconcile pass already provides: lifecycle.Reconcile re-publishes every running instance at brain startup (DISCOVERY.md # Restart durability), but a Start in between did not. The fix extends the same "Caddy block and Avahi record are siblings, lockstep" rule (DISCOVERY.md # The reconciler owns Avahi state) to Start.
- New
Manager.publishHost(ctx, inst) (host string, avahiOK bool)— extracted verbatim fromreassertRouting's inline publish block. Callshost.Publish(inst.Slug), uses the returned name as the route host (it may be the box-qualified<slug>-<box>.localcollision fallback), persists a changed name viastore.SetMDNSName, and falls back to the stored name then the reconstructed<slug>.localso the route always exists even when mDNS is down. Idempotent — re-publishing an already-announced name is a host-side no-op — which is what lets every re-assertion path call it freely. Sits next torouteHost, the passive sibling used by callers (Stop) that flip the splash without re-announcing. Startnow resolves its route host viapublishHostinstead ofrouteHost(one line), so a Start re-asserts mDNS and Caddy. The publish happens up-front, before the "starting" splash and thecompose up— mirroring install's step 9, not deferred to after-healthy. Rationale (DECISIONS.md2026-06-13): the name-lifecycle model keeps a name announced for an app's whole life with only the route content varying (Stop deliberately keeps<slug>.localresolving, pointing it at the stopped splash), so the name should resolve to the starting splash during recovery too; and onehostvalue then keys the starting splash, the real upstream, and the failed splash without divergence if the published name changed.reassertRouting(reconcile) now callspublishHostinstead of its own copy of the block — genuinely reusing the logic the issue pointed at, so the two paths can't drift. Behavior unchanged (same publish, same persist-on-change, same fallback, sameavahiOKreturn feeding the "avahi replay" startup summary); only the publish-failure log message normalized from"reconcile: mDNS publish"to the shared"mDNS publish failed (continuing)".- Stop is untouched — it must NOT re-publish (the stopped splash keeps resolving on the install-time announcement; explicitly out of scope per #153).
TestStartReassertsMDNSName— the regression guard. Install publishes once; Stop does not re-publish (count stays 1 — name-on-stop unchanged); then the test drops the fake host's entry group (dropPublished, simulating a host-agent restart) and asserts Start re-publishes (count → 2), re-announces the dropped name (isPublishedtrue again), and leaves the storedMDNSNamein sync — recovery without a brain restart.TestPublishHostBranches— drivespublishHost's non-happy paths directly to 100%: publish failure with no stored name → reconstructed<slug>.localfallback +avahiOKfalse + nothing persisted; and a returned name differing from the stored one (collision fallback) → persisted throughSetMDNSName.- Fake host gained
publishCount(slug)(assert a re-publish, not just eventual state),dropPublished(slug)(simulate a lost entry group), andpublishErr/publishNamefields (force the mDNS-down and collision-fallback paths). Default zero values preserve existing fake behavior exactly. make checkgreen (gofmt, vet, OpenAPI freshness, full Go suite). New code fully covered (publishHost100%; the Start call site and the regression are on covered paths). No web-ui, OpenAPI, or protocol change.
- The broader "mid-life host-agent restart while the brain runs" gap is still open for untouched apps. Start now recovers an app's name when the user starts it, but an app left
runningacross a host-agent restart still has a dark name until the next brain reboot. The general mitigation — the brain pollingGET /v1/system/statusforuptime_sdecreasing and replaying all names on detection — remains tracked inDISCOVERY.md# Restart durability anddocs/progress/avahi-dbus-publisher.md. #153 narrows the window for the Start-able case; it does not close that gap. - Install's step-9 publish block was left as its own copy (it also sets the local
inst.MDNSNamefield and runs inside the rollback-wrapped install transaction). Folding it ontopublishHostis a possible follow-up but was kept out of scope to keep the change surgical and off the install transaction's failure paths.