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:
@@ -134,6 +134,53 @@ describe("systemd-service — restartUserService", linuxOnly, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd-service — unitIsStale", linuxOnly, () => {
|
||||
it("is true when the unit names a plugin root that is no longer the current one", () => {
|
||||
// Invisible while the server runs, fatal at the next reboot: the unit
|
||||
// starts a path Claude Code garbage-collected after the update.
|
||||
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
|
||||
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), true);
|
||||
});
|
||||
|
||||
it("is false for a unit already naming the current root", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
|
||||
});
|
||||
|
||||
it("is false when there is no unit, and for one we do not own", () => {
|
||||
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
|
||||
|
||||
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
||||
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
|
||||
assert.equal(
|
||||
svc.unitIsStale("agenthub", "/plugin/root/new"),
|
||||
false,
|
||||
"a foreign unit is not ours to call stale, or to rewrite"
|
||||
);
|
||||
});
|
||||
|
||||
it("spawns nothing — the bootstrap fast path runs it on every SessionStart", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
calls = [];
|
||||
|
||||
svc.unitIsStale("agenthub", "/plugin/root/new");
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd-service — unitIsActive", linuxOnly, () => {
|
||||
it("is true only for `active`", () => {
|
||||
svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));
|
||||
assert.equal(svc.unitIsActive("agenthub"), true);
|
||||
|
||||
svc.__setExecImpl((args) => ({
|
||||
status: 3,
|
||||
stdout: args[0] === "is-active" ? "inactive\n" : "",
|
||||
}));
|
||||
assert.equal(svc.unitIsActive("agenthub"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd-service — unitIsOurs", linuxOnly, () => {
|
||||
it("is false when no unit exists at all", () => {
|
||||
fs.rmSync(svc.unitPath("agenthub"), { force: true });
|
||||
|
||||
Reference in New Issue
Block a user