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:
@@ -63,6 +63,36 @@ function unitIsOurs(name) {
|
||||
* later Claude Code session happened to run the bootstrap. An explicit
|
||||
* `systemctl stop` (what uninstall does) still stops it for good.
|
||||
*/
|
||||
/**
|
||||
* Does our unit still point at `root`? A plugin update moves the install to a
|
||||
* new cache directory, and a unit left naming the old one is a failure that
|
||||
* shows NOTHING until the next reboot — the dashboard simply does not come
|
||||
* back, from a path Claude Code garbage-collected weeks earlier.
|
||||
*
|
||||
* Pure fs, no `systemctl`: this is called from the bootstrap's fast path, which
|
||||
* runs on every single SessionStart and may not spawn a process.
|
||||
*
|
||||
* A foreign unit is never "stale" — it is not ours to judge or rewrite.
|
||||
*/
|
||||
function unitIsStale(name, root) {
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(unitPath(name), "utf8");
|
||||
} catch {
|
||||
return false; // no unit at all: nothing to be stale
|
||||
}
|
||||
if (!body.includes(MARKER)) return false;
|
||||
return !body.includes(`\nWorkingDirectory=${root}\n`);
|
||||
}
|
||||
|
||||
/** Is the unit currently running? Costs a `systemctl` call — never call it
|
||||
* from the fast path. */
|
||||
function unitIsActive(name) {
|
||||
if (!systemctlAvailable()) return false;
|
||||
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
|
||||
return String(res.stdout || "").trim() === "active";
|
||||
}
|
||||
|
||||
function unitContent({ description, workingDir, execStart, env }) {
|
||||
const envLines = Object.entries(env)
|
||||
.map(([k, v]) => `Environment=${k}=${v}`)
|
||||
@@ -178,6 +208,8 @@ function enableLinger() {
|
||||
module.exports = {
|
||||
systemctlAvailable,
|
||||
unitIsOurs,
|
||||
unitIsStale,
|
||||
unitIsActive,
|
||||
writeUserService,
|
||||
restartUserService,
|
||||
installUserService,
|
||||
|
||||
Reference in New Issue
Block a user