refactor: centralize daemon service start state flow

This commit is contained in:
Peter Steinberger
2026-03-24 22:48:26 -07:00
parent 5dec3dddc4
commit 258a214bcb
7 changed files with 290 additions and 84 deletions

View File

@@ -205,9 +205,38 @@ describe("runServiceRestart token drift", () => {
opts: { json: true },
});
expect(service.isLoaded).toHaveBeenCalledTimes(1);
expect(service.isLoaded).toHaveBeenCalled();
const payload = readJsonLog<{ result?: string; message?: string }>();
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
it("fails start when restarting a stopped installed service errors", async () => {
service.isLoaded.mockResolvedValue(false);
service.restart.mockRejectedValue(new Error("launchctl kickstart failed: permission denied"));
await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1");
const payload = readJsonLog<{ ok?: boolean; error?: string }>();
expect(payload.ok).toBe(false);
expect(payload.error).toContain("launchctl kickstart failed: permission denied");
});
it("falls back to not-loaded hints when start finds no install artifacts", async () => {
service.isLoaded.mockResolvedValue(false);
service.readCommand.mockResolvedValue(null);
await runServiceStart({
serviceNoun: "Gateway",
service,
renderStartHints: () => ["openclaw gateway install"],
opts: { json: true },
});
const payload = readJsonLog<{ ok?: boolean; result?: string; hints?: string[] }>();
expect(payload.ok).toBe(true);
expect(payload.result).toBe("not-loaded");
expect(payload.hints).toEqual(["openclaw gateway install"]);
expect(service.restart).not.toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@ import { formatConfigIssueLines } from "../../config/issue-format.js";
import { resolveIsNixMode } from "../../config/paths.js";
import { checkTokenDrift } from "../../daemon/service-audit.js";
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
import { describeGatewayServiceRestart } from "../../daemon/service.js";
import { describeGatewayServiceRestart, startGatewayService } from "../../daemon/service.js";
import type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
@@ -77,6 +77,17 @@ function createActionIO(params: { action: DaemonAction; json: boolean }) {
return { stdout, emit, fail };
}
function emitActionMessage(params: {
json: boolean;
emit: ReturnType<typeof createActionIO>["emit"];
payload: Omit<DaemonActionResponse, "action">;
}) {
params.emit(params.payload);
if (!params.json && params.payload.message) {
defaultRuntime.log(params.payload.message);
}
}
async function handleServiceNotLoaded(params: {
serviceNoun: string;
service: GatewayService;
@@ -200,12 +211,13 @@ export async function runServiceStart(params: {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createActionIO({ action: "start", json });
const loaded = await resolveServiceLoadedOrFail({
serviceNoun: params.serviceNoun,
service: params.service,
fail,
});
if (loaded === null) {
if (
(await resolveServiceLoadedOrFail({
serviceNoun: params.serviceNoun,
service: params.service,
fail,
})) === null
) {
return;
}
// Pre-flight config validation (#35862) — run for both loaded and not-loaded
@@ -219,71 +231,45 @@ export async function runServiceStart(params: {
return;
}
}
if (!loaded) {
// Service was stopped (e.g. `gateway stop` booted out the LaunchAgent).
// Attempt a restart, which handles re-bootstrapping the service. Without
// this, `start` after `stop` just prints hints and does nothing (#53878).
try {
const restartResult = await params.service.restart({ env: process.env, stdout });
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
const postLoaded = await params.service.isLoaded({ env: process.env }).catch(() => true);
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, postLoaded),
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return;
} catch {
// Bootstrap failed (e.g. plist was deleted, not just booted out).
// Fall through to the not-loaded hints.
try {
const startResult = await startGatewayService(params.service, { env: process.env, stdout });
if (startResult.outcome === "missing-install") {
await handleServiceNotLoaded({
serviceNoun: params.serviceNoun,
service: params.service,
loaded,
loaded: startResult.state.loaded,
renderStartHints: params.renderStartHints,
json,
emit,
});
return;
}
}
try {
const restartResult = await params.service.restart({ env: process.env, stdout });
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
if (startResult.outcome === "scheduled") {
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, {
outcome: "scheduled",
});
emitActionMessage({
json,
emit,
payload: {
ok: true,
result: "scheduled",
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
},
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return;
}
emit({
ok: true,
result: "started",
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
});
} catch (err) {
const hints = params.renderStartHints();
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
return;
}
let started = true;
try {
started = await params.service.isLoaded({ env: process.env });
} catch {
started = true;
}
emit({
ok: true,
result: "started",
service: buildDaemonServiceSnapshot(params.service, started),
});
}
export async function runServiceStop(params: {
@@ -371,16 +357,17 @@ export async function runServiceRestart(params: {
restartStatus: ReturnType<typeof describeGatewayServiceRestart>,
serviceLoaded: boolean,
) => {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, serviceLoaded),
warnings: warnings.length ? warnings : undefined,
emitActionMessage({
json,
emit,
payload: {
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, serviceLoaded),
warnings: warnings.length ? warnings : undefined,
},
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return true;
};

View File

@@ -42,9 +42,11 @@ export function resetLifecycleServiceMocks() {
service.stage.mockClear();
service.isLoaded.mockClear();
service.readCommand.mockClear();
service.readRuntime.mockClear();
service.restart.mockClear();
service.isLoaded.mockResolvedValue(true);
service.readCommand.mockResolvedValue({ programArguments: [], environment: {} });
service.readRuntime.mockResolvedValue({ status: "running" });
service.restart.mockResolvedValue({ outcome: "completed" });
}