fix(bootstrap): self-heal a systemd unit left on a retired plugin root

The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.

- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
  instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
  path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
  server keeps running and the corrected unit takes effect at the next
  start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
  detached-spawn fallback is guarded by needsServer so a stale unit alone
  cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
  stopped owns nothing: skipping the stop there and asking systemd to start
  would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
  come back after a reboot") and the fix. WARN, never FAIL — the dashboard
  in front of you works, and having no unit at all is a normal setup.

The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
This commit is contained in:
2026-09-11 16:45:47 +07:00
parent 49a041b1dc
commit c90dd7b147
9 changed files with 377 additions and 11 deletions
+34 -8
View File
@@ -107,6 +107,12 @@ function fastPathOk(rt = runtimeDir()) {
// pipelines drive. Reading one recorded flag keeps this hot path cheap — the
// actual check (manifest + every user SKILL.md) runs once, in the worker.
if (!state.skillPluginsSettled) return false;
// A live server says nothing about whether it will come back. When our unit
// still names a previous plugin cache directory, everything looks healthy
// until the next reboot — and then the dashboard is simply gone, from a path
// Claude Code deleted long ago. Reading one file is cheap enough for a hot
// path; `unitIsStale` deliberately spawns nothing.
if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
return serverIsLive();
}
@@ -518,7 +524,13 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
// before it can write state or point the unit at the new cache dir. That
// is the bug that left a dashboard dead after every self-update until the
// next Claude Code session happened to bootstrap again.
const systemdOwned = svc.systemctlAvailable() && svc.unitIsOurs("agenthub");
// ACTIVE matters, not merely present: a unit of ours that is installed but
// stopped owns nothing right now. If a detached server is holding the port
// in that state, treating systemd as the owner would skip the stop and then
// ask systemd to start a second server onto a bound port — a crash loop,
// courtesy of Restart=always.
const systemdOwned =
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
// After a plugin update the running server executes code from a cache
// directory Claude Code has already discarded — retire it first. Only
@@ -530,7 +542,13 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
waitForPortsFree();
}
if (movedRoot || !serverIsLive()) {
// Two different needs, deliberately not collapsed. `needsServer` is "the
// running process must be replaced"; `unitStale` is "the unit names a cache
// directory that is no longer this one", which is invisible until a reboot
// and must be repaired even while a perfectly healthy server is up.
const needsServer = movedRoot || !serverIsLive();
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
if (needsServer || unitStale) {
const unit = {
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
@@ -557,17 +575,25 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
: "unavailable";
if (result === "written" || result === "unchanged") {
if (result === "written") svc.enableLinger();
pendingSystemdRestart = systemdOwned;
// Only when the RUNNING process has to change. A stale unit beside a
// healthy server is now corrected on disk and left to take effect at
// the next start — restarting a working dashboard to fix a file that
// only matters at boot would be churn the user feels for no reason.
pendingSystemdRestart = systemdOwned && needsServer;
log(
rt,
result === "written"
? "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
: "systemd --user service already up to date"
result === "unchanged"
? "systemd --user service already up to date"
: unitStale && !needsServer
? "repointed the systemd --user service at the current plugin version (it named a retired one)"
: "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
);
} else {
} else if (needsServer) {
// No systemd (macOS/Windows/WSL without it), or the unit exists but
// wasn't ours to touch — same detached-spawn fallback as before,
// scoped to this session only.
// scoped to this session only. Guarded by `needsServer`: entering this
// block for a stale unit alone must never spawn a second server onto
// the port the live one already holds.
const pid = startDashboard(rt);
log(
rt,