From b31836317a4756bcd3e983c9d1773ae6bab68fd0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 12 Mar 2026 01:38:39 +0000 Subject: [PATCH] fix(cli): handle scheduled gateway restarts consistently --- src/cli/daemon-cli/lifecycle-core.test.ts | 20 +- src/cli/daemon-cli/lifecycle-core.ts | 46 ++++- src/cli/daemon-cli/lifecycle.test.ts | 20 ++ src/cli/daemon-cli/lifecycle.ts | 5 +- src/commands/configure.daemon.test.ts | 47 ++++- src/commands/configure.daemon.ts | 8 +- .../doctor-gateway-daemon-flow.test.ts | 194 ++++++++++++++++++ src/commands/doctor-gateway-daemon-flow.ts | 18 +- src/commands/status.service-summary.test.ts | 2 +- src/daemon/service.test.ts | 11 +- src/daemon/service.ts | 25 +++ src/wizard/onboarding.finalize.test.ts | 79 ++++++- src/wizard/onboarding.finalize.ts | 22 +- 13 files changed, 456 insertions(+), 41 deletions(-) create mode 100644 src/commands/doctor-gateway-daemon-flow.test.ts diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 90bb89983fb..ff66bd17653 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -40,11 +40,12 @@ vi.mock("../../runtime.js", () => ({ })); let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart; +let runServiceStart: typeof import("./lifecycle-core.js").runServiceStart; let runServiceStop: typeof import("./lifecycle-core.js").runServiceStop; describe("runServiceRestart token drift", () => { beforeAll(async () => { - ({ runServiceRestart, runServiceStop } = await import("./lifecycle-core.js")); + ({ runServiceRestart, runServiceStart, runServiceStop } = await import("./lifecycle-core.js")); }); beforeEach(() => { @@ -196,4 +197,21 @@ describe("runServiceRestart token drift", () => { expect(payload.result).toBe("scheduled"); expect(payload.message).toBe("restart scheduled, gateway will restart momentarily"); }); + + it("emits scheduled when service start routes through a scheduled restart", async () => { + service.restart.mockResolvedValue({ outcome: "scheduled" }); + + await runServiceStart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + }); + + expect(service.isLoaded).toHaveBeenCalledTimes(1); + const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); + const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string }; + expect(payload.result).toBe("scheduled"); + expect(payload.message).toBe("restart scheduled, gateway will restart momentarily"); + }); }); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 7d6365b97a0..a1ad4073584 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -4,6 +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 type { GatewayService } from "../../daemon/service.js"; import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js"; @@ -224,7 +225,20 @@ export async function runServiceStart(params: { } try { - await params.service.restart({ env: process.env, stdout }); + 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 (!json) { + defaultRuntime.log(restartStatus.message); + } + return; + } } catch (err) { const hints = params.renderStartHints(); fail(`${params.serviceNoun} start failed: ${String(err)}`, hints); @@ -318,7 +332,7 @@ export async function runServiceRestart(params: { renderStartHints: () => string[]; opts?: DaemonLifecycleOptions; checkTokenDrift?: boolean; - postRestartCheck?: (ctx: RestartPostCheckContext) => Promise; + postRestartCheck?: (ctx: RestartPostCheckContext) => Promise; onNotLoaded?: (ctx: NotLoadedActionContext) => Promise; }): Promise { const json = Boolean(params.opts?.json); @@ -407,22 +421,38 @@ export async function runServiceRestart(params: { if (loaded) { restartResult = await params.service.restart({ env: process.env, stdout }); } - if (restartResult.outcome === "scheduled") { - const message = `restart scheduled, ${params.serviceNoun.toLowerCase()} will restart momentarily`; + let restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult); + if (restartStatus.scheduled) { emit({ ok: true, - result: "scheduled", - message, + result: restartStatus.daemonActionResult, + message: restartStatus.message, service: buildDaemonServiceSnapshot(params.service, loaded), warnings: warnings.length ? warnings : undefined, }); if (!json) { - defaultRuntime.log(message); + defaultRuntime.log(restartStatus.message); } return true; } if (params.postRestartCheck) { - await params.postRestartCheck({ json, stdout, warnings, fail }); + const postRestartResult = await params.postRestartCheck({ json, stdout, warnings, fail }); + if (postRestartResult) { + restartStatus = describeGatewayServiceRestart(params.serviceNoun, postRestartResult); + if (restartStatus.scheduled) { + emit({ + ok: true, + result: restartStatus.daemonActionResult, + message: restartStatus.message, + service: buildDaemonServiceSnapshot(params.service, loaded), + warnings: warnings.length ? warnings : undefined, + }); + if (!json) { + defaultRuntime.log(restartStatus.message); + } + return true; + } + } } let restarted = loaded; if (loaded) { diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index f1e87fc4938..61899e4e78c 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -132,6 +132,7 @@ describe("runDaemonRestart health checks", () => { programArguments: ["openclaw", "gateway", "--port", "18789"], environment: {}, }); + service.restart.mockResolvedValue({ outcome: "completed" }); runServiceRestart.mockImplementation(async (params: RestartParams) => { const fail = (message: string, hints?: string[]) => { @@ -204,6 +205,25 @@ describe("runDaemonRestart health checks", () => { expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(2); }); + it("skips stale-pid retry health checks when the retry restart is only scheduled", async () => { + const unhealthy: RestartHealthSnapshot = { + healthy: false, + staleGatewayPids: [1993], + runtime: { status: "stopped" }, + portUsage: { port: 18789, status: "busy", listeners: [], hints: [] }, + }; + waitForGatewayHealthyRestart.mockResolvedValueOnce(unhealthy); + terminateStaleGatewayPids.mockResolvedValue([1993]); + service.restart.mockResolvedValueOnce({ outcome: "scheduled" }); + + const result = await runDaemonRestart({ json: true }); + + expect(result).toBe(true); + expect(terminateStaleGatewayPids).toHaveBeenCalledWith([1993]); + expect(service.restart).toHaveBeenCalledTimes(1); + expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1); + }); + it("fails restart when gateway remains unhealthy", async () => { const unhealthy: RestartHealthSnapshot = { healthy: false, diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 7fa7396d0b0..2b0775b0c48 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -286,7 +286,10 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi } await terminateStaleGatewayPids(health.staleGatewayPids); - await service.restart({ env: process.env, stdout }); + const retryRestart = await service.restart({ env: process.env, stdout }); + if (retryRestart.outcome === "scheduled") { + return retryRestart; + } health = await waitForGatewayHealthyRestart({ service, port: restartPort, diff --git a/src/commands/configure.daemon.test.ts b/src/commands/configure.daemon.test.ts index 9a7aa76e0c8..11b54dc6b19 100644 --- a/src/commands/configure.daemon.test.ts +++ b/src/commands/configure.daemon.test.ts @@ -1,13 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const withProgress = vi.hoisted(() => vi.fn(async (_opts, run) => run({ setLabel: vi.fn() }))); +const progressSetLabel = vi.hoisted(() => vi.fn()); +const withProgress = vi.hoisted(() => + vi.fn(async (_opts, run) => run({ setLabel: progressSetLabel })), +); const loadConfig = vi.hoisted(() => vi.fn()); const resolveGatewayInstallToken = vi.hoisted(() => vi.fn()); const buildGatewayInstallPlan = vi.hoisted(() => vi.fn()); const note = vi.hoisted(() => vi.fn()); const serviceIsLoaded = vi.hoisted(() => vi.fn(async () => false)); const serviceInstall = vi.hoisted(() => vi.fn(async () => {})); +const serviceRestart = vi.hoisted(() => + vi.fn<() => Promise<{ outcome: "completed" } | { outcome: "scheduled" }>>(async () => ({ + outcome: "completed", + })), +); const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {})); +const select = vi.hoisted(() => vi.fn(async () => "node")); vi.mock("../cli/progress.js", () => ({ withProgress, @@ -32,7 +41,7 @@ vi.mock("../terminal/note.js", () => ({ vi.mock("./configure.shared.js", () => ({ confirm: vi.fn(async () => true), - select: vi.fn(async () => "node"), + select, })); vi.mock("./daemon-runtime.js", () => ({ @@ -40,12 +49,17 @@ vi.mock("./daemon-runtime.js", () => ({ GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }], })); -vi.mock("../daemon/service.js", () => ({ - resolveGatewayService: vi.fn(() => ({ - isLoaded: serviceIsLoaded, - install: serviceInstall, - })), -})); +vi.mock("../daemon/service.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGatewayService: vi.fn(() => ({ + isLoaded: serviceIsLoaded, + install: serviceInstall, + restart: serviceRestart, + })), + }; +}); vi.mock("./onboard-helpers.js", () => ({ guardCancel: (value: unknown) => value, @@ -60,8 +74,10 @@ const { maybeInstallDaemon } = await import("./configure.daemon.js"); describe("maybeInstallDaemon", () => { beforeEach(() => { vi.clearAllMocks(); + progressSetLabel.mockReset(); serviceIsLoaded.mockResolvedValue(false); serviceInstall.mockResolvedValue(undefined); + serviceRestart.mockResolvedValue({ outcome: "completed" }); loadConfig.mockReturnValue({}); resolveGatewayInstallToken.mockResolvedValue({ token: undefined, @@ -152,4 +168,19 @@ describe("maybeInstallDaemon", () => { expect(serviceInstall).toHaveBeenCalledTimes(1); }); + + it("shows restart scheduled when a loaded service defers restart handoff", async () => { + serviceIsLoaded.mockResolvedValue(true); + select.mockResolvedValueOnce("restart"); + serviceRestart.mockResolvedValueOnce({ outcome: "scheduled" }); + + await maybeInstallDaemon({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + port: 18789, + }); + + expect(serviceRestart).toHaveBeenCalledTimes(1); + expect(serviceInstall).not.toHaveBeenCalled(); + expect(progressSetLabel).toHaveBeenLastCalledWith("Gateway service restart scheduled."); + }); }); diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index 4f943982a38..64272c9e2bc 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -1,6 +1,6 @@ import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; -import { resolveGatewayService } from "../daemon/service.js"; +import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../daemon/systemd.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; @@ -50,11 +50,13 @@ export async function maybeInstallDaemon(params: { { label: "Gateway service", indeterminate: true, delayMs: 0 }, async (progress) => { progress.setLabel("Restarting Gateway service…"); - await service.restart({ + const restartResult = await service.restart({ env: process.env, stdout: process.stdout, }); - progress.setLabel("Gateway service restarted."); + progress.setLabel( + describeGatewayServiceRestart("Gateway", restartResult).progressMessage, + ); }, ); shouldCheckLinger = true; diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts new file mode 100644 index 00000000000..02c0b885bb0 --- /dev/null +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const service = vi.hoisted(() => ({ + isLoaded: vi.fn(), + readRuntime: vi.fn(), + restart: vi.fn(), + install: vi.fn(), + readCommand: vi.fn(), +})); +const note = vi.hoisted(() => vi.fn()); +const sleep = vi.hoisted(() => vi.fn(async () => {})); +const healthCommand = vi.hoisted(() => vi.fn(async () => {})); +const inspectPortUsage = vi.hoisted(() => vi.fn()); +const readLastGatewayErrorLine = vi.hoisted(() => vi.fn(async () => null)); + +vi.mock("../config/config.js", () => ({ + resolveGatewayPort: vi.fn(() => 18789), +})); + +vi.mock("../daemon/constants.js", () => ({ + resolveGatewayLaunchAgentLabel: vi.fn(() => "ai.openclaw.gateway"), + resolveNodeLaunchAgentLabel: vi.fn(() => "ai.openclaw.node"), +})); + +vi.mock("../daemon/diagnostics.js", () => ({ + readLastGatewayErrorLine, +})); + +vi.mock("../daemon/launchd.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isLaunchAgentListed: vi.fn(async () => false), + isLaunchAgentLoaded: vi.fn(async () => false), + launchAgentPlistExists: vi.fn(async () => false), + repairLaunchAgentBootstrap: vi.fn(async () => ({ ok: true })), + }; +}); + +vi.mock("../daemon/service.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGatewayService: () => service, + }; +}); + +vi.mock("../daemon/systemd-hints.js", () => ({ + renderSystemdUnavailableHints: vi.fn(() => []), +})); + +vi.mock("../daemon/systemd.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isSystemdUserServiceAvailable: vi.fn(async () => true), + }; +}); + +vi.mock("../infra/ports.js", () => ({ + inspectPortUsage, + formatPortDiagnostics: vi.fn(() => []), +})); + +vi.mock("../infra/wsl.js", () => ({ + isWSL: vi.fn(async () => false), +})); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +vi.mock("../utils.js", () => ({ + sleep, +})); + +vi.mock("./daemon-install-helpers.js", () => ({ + buildGatewayInstallPlan: vi.fn(), + gatewayInstallErrorHint: vi.fn(() => "hint"), +})); + +vi.mock("./doctor-format.js", () => ({ + buildGatewayRuntimeHints: vi.fn(() => []), + formatGatewayRuntimeSummary: vi.fn(() => null), +})); + +vi.mock("./gateway-install-token.js", () => ({ + resolveGatewayInstallToken: vi.fn(), +})); + +vi.mock("./health-format.js", () => ({ + formatHealthCheckFailure: vi.fn(() => "health failed"), +})); + +vi.mock("./health.js", () => ({ + healthCommand, +})); + +describe("maybeRepairGatewayDaemon", () => { + let maybeRepairGatewayDaemon: typeof import("./doctor-gateway-daemon-flow.js").maybeRepairGatewayDaemon; + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + + beforeAll(async () => { + ({ maybeRepairGatewayDaemon } = await import("./doctor-gateway-daemon-flow.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + service.isLoaded.mockResolvedValue(true); + service.readRuntime.mockResolvedValue({ status: "running" }); + service.restart.mockResolvedValue({ outcome: "completed" }); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }); + }); + + afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + function setPlatform(platform: NodeJS.Platform) { + if (!originalPlatformDescriptor) { + return; + } + Object.defineProperty(process, "platform", { + ...originalPlatformDescriptor, + value: platform, + }); + } + + function createPrompter(confirmImpl: (message: string) => boolean) { + return { + confirm: vi.fn(), + confirmRepair: vi.fn(), + confirmAggressive: vi.fn(), + confirmSkipInNonInteractive: vi.fn(async ({ message }: { message: string }) => + confirmImpl(message), + ), + select: vi.fn(), + shouldRepair: false, + shouldForce: false, + }; + } + + it("skips restart verification when a running service restart is only scheduled", async () => { + setPlatform("linux"); + service.restart.mockResolvedValueOnce({ outcome: "scheduled" }); + + await maybeRepairGatewayDaemon({ + cfg: { gateway: {} }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + prompter: createPrompter((message) => message === "Restart gateway service now?"), + options: { deep: false }, + gatewayDetailsMessage: "details", + healthOk: false, + }); + + expect(service.restart).toHaveBeenCalledTimes(1); + expect(note).toHaveBeenCalledWith( + "restart scheduled, gateway will restart momentarily", + "Gateway", + ); + expect(sleep).not.toHaveBeenCalled(); + expect(healthCommand).not.toHaveBeenCalled(); + }); + + it("skips start verification when a stopped service start is only scheduled", async () => { + setPlatform("linux"); + service.readRuntime.mockResolvedValue({ status: "stopped" }); + service.restart.mockResolvedValueOnce({ outcome: "scheduled" }); + + await maybeRepairGatewayDaemon({ + cfg: { gateway: {} }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + prompter: createPrompter((message) => message === "Start gateway service now?"), + options: { deep: false }, + gatewayDetailsMessage: "details", + healthOk: false, + }); + + expect(service.restart).toHaveBeenCalledTimes(1); + expect(note).toHaveBeenCalledWith( + "restart scheduled, gateway will restart momentarily", + "Gateway", + ); + expect(sleep).not.toHaveBeenCalled(); + expect(healthCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 4fd8df3490b..c476efa615f 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -12,7 +12,7 @@ import { launchAgentPlistExists, repairLaunchAgentBootstrap, } from "../daemon/launchd.js"; -import { resolveGatewayService } from "../daemon/service.js"; +import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js"; import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; @@ -235,11 +235,16 @@ export async function maybeRepairGatewayDaemon(params: { initialValue: true, }); if (start) { - await service.restart({ + const restartResult = await service.restart({ env: process.env, stdout: process.stdout, }); - await sleep(1500); + const restartStatus = describeGatewayServiceRestart("Gateway", restartResult); + if (!restartStatus.scheduled) { + await sleep(1500); + } else { + note(restartStatus.message, "Gateway"); + } } } @@ -257,10 +262,15 @@ export async function maybeRepairGatewayDaemon(params: { initialValue: true, }); if (restart) { - await service.restart({ + const restartResult = await service.restart({ env: process.env, stdout: process.stdout, }); + const restartStatus = describeGatewayServiceRestart("Gateway", restartResult); + if (restartStatus.scheduled) { + note(restartStatus.message, "Gateway"); + return; + } await sleep(1500); try { await healthCommand({ json: false, timeoutMs: 10_000 }, params.runtime); diff --git a/src/commands/status.service-summary.test.ts b/src/commands/status.service-summary.test.ts index fb51d8036e4..f1a688ea092 100644 --- a/src/commands/status.service-summary.test.ts +++ b/src/commands/status.service-summary.test.ts @@ -10,7 +10,7 @@ function createService(overrides: Partial): GatewayService { install: vi.fn(async () => {}), uninstall: vi.fn(async () => {}), stop: vi.fn(async () => {}), - restart: vi.fn(async () => {}), + restart: vi.fn(async () => ({ outcome: "completed" as const })), isLoaded: vi.fn(async () => false), readCommand: vi.fn(async () => null), readRuntime: vi.fn(async () => ({ status: "stopped" as const })), diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index 19811e49699..ea2c53e8e1a 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { resolveGatewayService } from "./service.js"; +import { describeGatewayServiceRestart, resolveGatewayService } from "./service.js"; const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -37,4 +37,13 @@ describe("resolveGatewayService", () => { setPlatform("aix"); expect(() => resolveGatewayService()).toThrow("Gateway service install not supported on aix"); }); + + it("describes scheduled restart handoffs consistently", () => { + expect(describeGatewayServiceRestart("Gateway", { outcome: "scheduled" })).toEqual({ + scheduled: true, + daemonActionResult: "scheduled", + message: "restart scheduled, gateway will restart momentarily", + progressMessage: "Gateway service restart scheduled.", + }); + }); }); diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 85d60531d65..8083ce4b5e1 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -66,6 +66,31 @@ export type GatewayService = { readRuntime: (env: GatewayServiceEnv) => Promise; }; +export function describeGatewayServiceRestart( + serviceNoun: string, + result: GatewayServiceRestartResult, +): { + scheduled: boolean; + daemonActionResult: "restarted" | "scheduled"; + message: string; + progressMessage: string; +} { + if (result.outcome === "scheduled") { + return { + scheduled: true, + daemonActionResult: "scheduled", + message: `restart scheduled, ${serviceNoun.toLowerCase()} will restart momentarily`, + progressMessage: `${serviceNoun} service restart scheduled.`, + }; + } + return { + scheduled: false, + daemonActionResult: "restarted", + message: `${serviceNoun} service restarted.`, + progressMessage: `${serviceNoun} service restarted.`, + }; +} + type SupportedGatewayServicePlatform = "darwin" | "linux" | "win32"; const GATEWAY_SERVICE_REGISTRY: Record = { diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts index 314d22d8ca3..0fa67d16a8f 100644 --- a/src/wizard/onboarding.finalize.test.ts +++ b/src/wizard/onboarding.finalize.test.ts @@ -13,6 +13,13 @@ const buildGatewayInstallPlan = vi.hoisted(() => })), ); const gatewayServiceInstall = vi.hoisted(() => vi.fn(async () => {})); +const gatewayServiceRestart = vi.hoisted(() => + vi.fn<() => Promise<{ outcome: "completed" } | { outcome: "scheduled" }>>(async () => ({ + outcome: "completed", + })), +); +const gatewayServiceUninstall = vi.hoisted(() => vi.fn(async () => {})); +const gatewayServiceIsLoaded = vi.hoisted(() => vi.fn(async () => false)); const resolveGatewayInstallToken = vi.hoisted(() => vi.fn(async () => ({ token: undefined, @@ -56,14 +63,18 @@ vi.mock("../commands/health.js", () => ({ healthCommand: vi.fn(async () => {}), })); -vi.mock("../daemon/service.js", () => ({ - resolveGatewayService: vi.fn(() => ({ - isLoaded: vi.fn(async () => false), - restart: vi.fn(async () => {}), - uninstall: vi.fn(async () => {}), - install: gatewayServiceInstall, - })), -})); +vi.mock("../daemon/service.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGatewayService: vi.fn(() => ({ + isLoaded: gatewayServiceIsLoaded, + restart: gatewayServiceRestart, + uninstall: gatewayServiceUninstall, + install: gatewayServiceInstall, + })), + }; +}); vi.mock("../daemon/systemd.js", async (importOriginal) => { const actual = await importOriginal(); @@ -113,6 +124,11 @@ describe("finalizeOnboardingWizard", () => { setupOnboardingShellCompletion.mockClear(); buildGatewayInstallPlan.mockClear(); gatewayServiceInstall.mockClear(); + gatewayServiceIsLoaded.mockReset(); + gatewayServiceIsLoaded.mockResolvedValue(false); + gatewayServiceRestart.mockReset(); + gatewayServiceRestart.mockResolvedValue({ outcome: "completed" }); + gatewayServiceUninstall.mockReset(); resolveGatewayInstallToken.mockClear(); isSystemdUserServiceAvailable.mockReset(); isSystemdUserServiceAvailable.mockResolvedValue(true); @@ -244,4 +260,51 @@ describe("finalizeOnboardingWizard", () => { expectFirstOnboardingInstallPlanCallOmitsToken(); expect(gatewayServiceInstall).toHaveBeenCalledTimes(1); }); + + it("stops after a scheduled restart instead of reinstalling the service", async () => { + const progressUpdate = vi.fn(); + const progressStop = vi.fn(); + gatewayServiceIsLoaded.mockResolvedValue(true); + gatewayServiceRestart.mockResolvedValueOnce({ outcome: "scheduled" }); + const prompter = buildWizardPrompter({ + select: vi.fn(async (params: { message: string }) => { + if (params.message === "Gateway service already installed") { + return "restart"; + } + return "later"; + }) as never, + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: progressUpdate, stop: progressStop })), + }); + + await finalizeOnboardingWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: true, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(gatewayServiceRestart).toHaveBeenCalledTimes(1); + expect(gatewayServiceInstall).not.toHaveBeenCalled(); + expect(gatewayServiceUninstall).not.toHaveBeenCalled(); + expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…"); + expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled."); + }); }); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index fdb1143933c..b218e160ed5 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -23,7 +23,7 @@ import { } from "../commands/onboard-helpers.js"; import type { OnboardOptions } from "../commands/onboard-types.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveGatewayService } from "../daemon/service.js"; +import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -53,14 +53,16 @@ export async function finalizeOnboardingWizard( const withWizardProgress = async ( label: string, - options: { doneMessage?: string }, + options: { doneMessage?: string | (() => string | undefined) }, work: (progress: { update: (message: string) => void }) => Promise, ): Promise => { const progress = prompter.progress(label); try { return await work(progress); } finally { - progress.stop(options.doneMessage); + progress.stop( + typeof options.doneMessage === "function" ? options.doneMessage() : options.doneMessage, + ); } }; @@ -128,6 +130,7 @@ export async function finalizeOnboardingWizard( } const service = resolveGatewayService(); const loaded = await service.isLoaded({ env: process.env }); + let restartWasScheduled = false; if (loaded) { const action = await prompter.select({ message: "Gateway service already installed", @@ -138,15 +141,19 @@ export async function finalizeOnboardingWizard( ], }); if (action === "restart") { + let restartDoneMessage = "Gateway service restarted."; await withWizardProgress( "Gateway service", - { doneMessage: "Gateway service restarted." }, + { doneMessage: () => restartDoneMessage }, async (progress) => { progress.update("Restarting Gateway service…"); - await service.restart({ + const restartResult = await service.restart({ env: process.env, stdout: process.stdout, }); + const restartStatus = describeGatewayServiceRestart("Gateway", restartResult); + restartDoneMessage = restartStatus.progressMessage; + restartWasScheduled = restartStatus.scheduled; }, ); } else if (action === "reinstall") { @@ -161,7 +168,10 @@ export async function finalizeOnboardingWizard( } } - if (!loaded || (loaded && !(await service.isLoaded({ env: process.env })))) { + if ( + !loaded || + (!restartWasScheduled && loaded && !(await service.isLoaded({ env: process.env }))) + ) { const progress = prompter.progress("Gateway service"); let installError: string | null = null; try {