diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc17c5dcfb..16a29952160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -229,6 +229,7 @@ Docs: https://docs.openclaw.ai - Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing `sessionKey` rolls to a new `sessionId` across auto-reply, command, and isolated cron session resolvers, so `AGENTS.md`/`MEMORY.md`/`USER.md` updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm. - Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman. - Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running `systemctl --user is-enabled`, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble. +- Gateway/Windows restart supervision: relaunch task-managed gateways through Scheduled Task with quoted helper-script command paths, distinguish restart-capable supervisors per platform, and stop orphaned Windows gateway children during self-restart. (#38825) Thanks @obviyus. ## 2026.3.2 diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 361817c8cb1..c6b7d5ea21e 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -75,7 +75,9 @@ export async function runGatewayLoop(params: { `full process restart failed (${respawn.detail ?? "unknown error"}); falling back to in-process restart`, ); } else { - gatewayLog.info("restart mode: in-process restart (OPENCLAW_NO_RESPAWN)"); + gatewayLog.info( + `restart mode: in-process restart (${respawn.detail ?? "OPENCLAW_NO_RESPAWN"})`, + ); } if (hadLock && !(await reacquireLockForInProcessRestart())) { return; diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 18888c27f53..1e15556d89e 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -298,11 +298,25 @@ describe("restart-helper", () => { await runRestartScript(scriptPath); - expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/c", scriptPath], { + expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", scriptPath], { detached: true, stdio: "ignore", }); expect(mockChild.unref).toHaveBeenCalled(); }); + + it("quotes cmd.exe /c paths with metacharacters on Windows", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + const scriptPath = "C:\\Temp\\me&(ow)\\fake-script.bat"; + const mockChild = { unref: vi.fn() }; + vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess); + + await runRestartScript(scriptPath); + + expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", `"${scriptPath}"`], { + detached: true, + stdio: "ignore", + }); + }); }); }); diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index 4f7d45aab0c..02ac29d03bb 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js"; +import { quoteCmdScriptArg } from "../../daemon/cmd-argv.js"; import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, @@ -161,7 +162,7 @@ del "%~f0" export async function runRestartScript(scriptPath: string): Promise { const isWindows = process.platform === "win32"; const file = isWindows ? "cmd.exe" : "/bin/sh"; - const args = isWindows ? ["/c", scriptPath] : [scriptPath]; + const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath]; const child = spawn(file, args, { detached: true, diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 4080cd88fcf..f1dcb6e6f6f 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -278,6 +278,7 @@ describe("buildServiceEnvironment", () => { expect(env.OPENCLAW_SERVICE_KIND).toBe("gateway"); expect(typeof env.OPENCLAW_SERVICE_VERSION).toBe("string"); expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway.service"); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway"); if (process.platform === "darwin") { expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.gateway"); } @@ -305,6 +306,7 @@ describe("buildServiceEnvironment", () => { port: 18789, }); expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway-work.service"); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway (work)"); if (process.platform === "darwin") { expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.work"); } diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index f0534746aa7..181e45a7590 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -6,6 +6,7 @@ import { GATEWAY_SERVICE_MARKER, resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, + resolveGatewayWindowsTaskName, NODE_SERVICE_KIND, NODE_SERVICE_MARKER, NODE_WINDOWS_TASK_SCRIPT_NAME, @@ -262,6 +263,7 @@ export function buildServiceEnvironment(params: { OPENCLAW_GATEWAY_TOKEN: token, OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel, OPENCLAW_SYSTEMD_UNIT: systemdUnit, + OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile), OPENCLAW_SERVICE_MARKER: GATEWAY_SERVICE_MARKER, OPENCLAW_SERVICE_KIND: GATEWAY_SERVICE_KIND, OPENCLAW_SERVICE_VERSION: VERSION, diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 06591711c81..4a18a797607 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -67,11 +67,14 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it("returns supervised when launchd/systemd hints are present", () => { + it("returns supervised when launchd hints are present on macOS", () => { clearSupervisorHints(); + setPlatform("darwin"); process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; + triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" }); const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); + expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce(); expect(spawnMock).not.toHaveBeenCalled(); }); @@ -110,6 +113,7 @@ describe("restartGatewayProcessWithFreshPid", () => { it("spawns detached child with current exec argv", () => { delete process.env.OPENCLAW_NO_RESPAWN; clearSupervisorHints(); + setPlatform("linux"); process.execArgv = ["--import", "tsx"]; process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"]; spawnMock.mockReturnValue({ pid: 4242, unref: vi.fn() }); @@ -134,23 +138,68 @@ describe("restartGatewayProcessWithFreshPid", () => { it("returns supervised when OPENCLAW_SYSTEMD_UNIT is set", () => { clearSupervisorHints(); + setPlatform("linux"); process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway.service"; const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); expect(spawnMock).not.toHaveBeenCalled(); }); - it("returns supervised when OPENCLAW_SERVICE_MARKER is set", () => { + it("returns supervised when OpenClaw gateway task markers are set on Windows", () => { clearSupervisorHints(); - process.env.OPENCLAW_SERVICE_MARKER = "gateway"; + setPlatform("win32"); + process.env.OPENCLAW_SERVICE_MARKER = "openclaw"; + process.env.OPENCLAW_SERVICE_KIND = "gateway"; + triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "schtasks" }); const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); + expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("keeps generic service markers out of non-Windows supervisor detection", () => { + clearSupervisorHints(); + setPlatform("linux"); + process.env.OPENCLAW_SERVICE_MARKER = "openclaw"; + process.env.OPENCLAW_SERVICE_KIND = "gateway"; + spawnMock.mockReturnValue({ pid: 4242, unref: vi.fn() }); + + const result = restartGatewayProcessWithFreshPid(); + + expect(result).toEqual({ mode: "spawned", pid: 4242 }); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); + }); + + it("returns disabled on Windows without Scheduled Task markers", () => { + clearSupervisorHints(); + setPlatform("win32"); + + const result = restartGatewayProcessWithFreshPid(); + + expect(result.mode).toBe("disabled"); + expect(result.detail).toContain("Scheduled Task"); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("ignores node task script hints for gateway restart detection on Windows", () => { + clearSupervisorHints(); + setPlatform("win32"); + process.env.OPENCLAW_TASK_SCRIPT = "C:\\openclaw\\node.cmd"; + process.env.OPENCLAW_TASK_SCRIPT_NAME = "node.cmd"; + process.env.OPENCLAW_SERVICE_MARKER = "openclaw"; + process.env.OPENCLAW_SERVICE_KIND = "node"; + + const result = restartGatewayProcessWithFreshPid(); + + expect(result.mode).toBe("disabled"); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); }); it("returns failed when spawn throws", () => { delete process.env.OPENCLAW_NO_RESPAWN; clearSupervisorHints(); + setPlatform("linux"); spawnMock.mockImplementation(() => { throw new Error("spawn failed"); diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts index 554a1f9a93c..0edc43f2de4 100644 --- a/src/infra/process-respawn.ts +++ b/src/infra/process-respawn.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { triggerOpenClawRestart } from "./restart.js"; -import { hasSupervisorHint } from "./supervisor-markers.js"; +import { detectRespawnSupervisor } from "./supervisor-markers.js"; type RespawnMode = "spawned" | "supervised" | "disabled" | "failed"; @@ -18,13 +18,9 @@ function isTruthy(value: string | undefined): boolean { return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } -function isLikelySupervisedProcess(env: NodeJS.ProcessEnv = process.env): boolean { - return hasSupervisorHint(env); -} - /** * Attempt to restart this process with a fresh PID. - * - supervised environments (launchd/systemd): caller should exit and let supervisor restart + * - supervised environments (launchd/systemd/schtasks): caller should exit and let supervisor restart * - OPENCLAW_NO_RESPAWN=1: caller should keep in-process restart behavior (tests/dev) * - otherwise: spawn detached child with current argv/execArgv, then caller exits */ @@ -32,20 +28,27 @@ export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult { if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) { return { mode: "disabled" }; } - if (isLikelySupervisedProcess(process.env)) { - // On macOS under launchd, actively kickstart the supervised service to - // bypass ThrottleInterval delays for intentional restarts. - if (process.platform === "darwin" && process.env.OPENCLAW_LAUNCHD_LABEL?.trim()) { + const supervisor = detectRespawnSupervisor(process.env); + if (supervisor) { + if (supervisor === "launchd" || supervisor === "schtasks") { const restart = triggerOpenClawRestart(); if (!restart.ok) { return { mode: "failed", - detail: restart.detail ?? "launchctl kickstart failed", + detail: restart.detail ?? `${restart.method} restart failed`, }; } } return { mode: "supervised" }; } + if (process.platform === "win32") { + // Detached respawn is unsafe on Windows without an identified Scheduled Task: + // the child becomes orphaned if the original process exits. + return { + mode: "disabled", + detail: "win32: detached respawn unsupported without Scheduled Task markers", + }; + } try { const args = [...process.execArgv, ...process.argv.slice(1)]; diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 3f65cfc1614..ddb4352e5ca 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -7,10 +7,11 @@ import { } from "../daemon/constants.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } from "./restart-stale-pids.js"; +import { relaunchGatewayScheduledTask } from "./windows-task-restart.js"; export type RestartAttempt = { ok: boolean; - method: "launchctl" | "systemd" | "supervisor"; + method: "launchctl" | "systemd" | "schtasks" | "supervisor"; detail?: string; tried?: string[]; }; @@ -296,36 +297,41 @@ export function triggerOpenClawRestart(): RestartAttempt { cleanStaleGatewayProcessesSync(); const tried: string[] = []; - if (process.platform !== "darwin") { - if (process.platform === "linux") { - const unit = normalizeSystemdUnit( - process.env.OPENCLAW_SYSTEMD_UNIT, - process.env.OPENCLAW_PROFILE, - ); - const userArgs = ["--user", "restart", unit]; - tried.push(`systemctl ${userArgs.join(" ")}`); - const userRestart = spawnSync("systemctl", userArgs, { - encoding: "utf8", - timeout: SPAWN_TIMEOUT_MS, - }); - if (!userRestart.error && userRestart.status === 0) { - return { ok: true, method: "systemd", tried }; - } - const systemArgs = ["restart", unit]; - tried.push(`systemctl ${systemArgs.join(" ")}`); - const systemRestart = spawnSync("systemctl", systemArgs, { - encoding: "utf8", - timeout: SPAWN_TIMEOUT_MS, - }); - if (!systemRestart.error && systemRestart.status === 0) { - return { ok: true, method: "systemd", tried }; - } - const detail = [ - `user: ${formatSpawnDetail(userRestart)}`, - `system: ${formatSpawnDetail(systemRestart)}`, - ].join("; "); - return { ok: false, method: "systemd", detail, tried }; + if (process.platform === "linux") { + const unit = normalizeSystemdUnit( + process.env.OPENCLAW_SYSTEMD_UNIT, + process.env.OPENCLAW_PROFILE, + ); + const userArgs = ["--user", "restart", unit]; + tried.push(`systemctl ${userArgs.join(" ")}`); + const userRestart = spawnSync("systemctl", userArgs, { + encoding: "utf8", + timeout: SPAWN_TIMEOUT_MS, + }); + if (!userRestart.error && userRestart.status === 0) { + return { ok: true, method: "systemd", tried }; } + const systemArgs = ["restart", unit]; + tried.push(`systemctl ${systemArgs.join(" ")}`); + const systemRestart = spawnSync("systemctl", systemArgs, { + encoding: "utf8", + timeout: SPAWN_TIMEOUT_MS, + }); + if (!systemRestart.error && systemRestart.status === 0) { + return { ok: true, method: "systemd", tried }; + } + const detail = [ + `user: ${formatSpawnDetail(userRestart)}`, + `system: ${formatSpawnDetail(systemRestart)}`, + ].join("; "); + return { ok: false, method: "systemd", detail, tried }; + } + + if (process.platform === "win32") { + return relaunchGatewayScheduledTask(process.env); + } + + if (process.platform !== "darwin") { return { ok: false, method: "supervisor", diff --git a/src/infra/supervisor-markers.ts b/src/infra/supervisor-markers.ts index 231bece5e3d..f024ddeca2e 100644 --- a/src/infra/supervisor-markers.ts +++ b/src/infra/supervisor-markers.ts @@ -1,20 +1,52 @@ -export const SUPERVISOR_HINT_ENV_VARS = [ - // macOS launchd +const LAUNCHD_SUPERVISOR_HINT_ENV_VARS = [ "LAUNCH_JOB_LABEL", "LAUNCH_JOB_NAME", - // OpenClaw service env markers "OPENCLAW_LAUNCHD_LABEL", +] as const; + +const SYSTEMD_SUPERVISOR_HINT_ENV_VARS = [ "OPENCLAW_SYSTEMD_UNIT", - "OPENCLAW_SERVICE_MARKER", - // Linux systemd "INVOCATION_ID", "SYSTEMD_EXEC_PID", "JOURNAL_STREAM", ] as const; -export function hasSupervisorHint(env: NodeJS.ProcessEnv = process.env): boolean { - return SUPERVISOR_HINT_ENV_VARS.some((key) => { +const WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS = ["OPENCLAW_WINDOWS_TASK_NAME"] as const; + +export const SUPERVISOR_HINT_ENV_VARS = [ + ...LAUNCHD_SUPERVISOR_HINT_ENV_VARS, + ...SYSTEMD_SUPERVISOR_HINT_ENV_VARS, + ...WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS, + "OPENCLAW_SERVICE_MARKER", + "OPENCLAW_SERVICE_KIND", +] as const; + +export type RespawnSupervisor = "launchd" | "systemd" | "schtasks"; + +function hasAnyHint(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { + return keys.some((key) => { const value = env[key]; return typeof value === "string" && value.trim().length > 0; }); } + +export function detectRespawnSupervisor( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): RespawnSupervisor | null { + if (platform === "darwin") { + return hasAnyHint(env, LAUNCHD_SUPERVISOR_HINT_ENV_VARS) ? "launchd" : null; + } + if (platform === "linux") { + return hasAnyHint(env, SYSTEMD_SUPERVISOR_HINT_ENV_VARS) ? "systemd" : null; + } + if (platform === "win32") { + if (hasAnyHint(env, WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS)) { + return "schtasks"; + } + const marker = env.OPENCLAW_SERVICE_MARKER?.trim(); + const serviceKind = env.OPENCLAW_SERVICE_KIND?.trim(); + return marker && serviceKind === "gateway" ? "schtasks" : null; + } + return null; +} diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts new file mode 100644 index 00000000000..1a25a7a7415 --- /dev/null +++ b/src/infra/windows-task-restart.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureFullEnv } from "../test-utils/env.js"; + +const spawnMock = vi.hoisted(() => vi.fn()); +const resolvePreferredOpenClawTmpDirMock = vi.hoisted(() => vi.fn(() => os.tmpdir())); + +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), +})); +vi.mock("./tmp-openclaw-dir.js", () => ({ + resolvePreferredOpenClawTmpDir: () => resolvePreferredOpenClawTmpDirMock(), +})); + +import { relaunchGatewayScheduledTask } from "./windows-task-restart.js"; + +const envSnapshot = captureFullEnv(); +const createdScriptPaths = new Set(); +const createdTmpDirs = new Set(); + +function decodeCmdPathArg(value: string): string { + const trimmed = value.trim(); + const withoutQuotes = + trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed; + return withoutQuotes.replace(/\^!/g, "!").replace(/%%/g, "%"); +} + +afterEach(() => { + envSnapshot.restore(); + spawnMock.mockReset(); + resolvePreferredOpenClawTmpDirMock.mockReset(); + resolvePreferredOpenClawTmpDirMock.mockReturnValue(os.tmpdir()); + for (const scriptPath of createdScriptPaths) { + try { + fs.unlinkSync(scriptPath); + } catch { + // Best-effort cleanup for temp helper scripts created in tests. + } + } + createdScriptPaths.clear(); + for (const tmpDir of createdTmpDirs) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup for test temp roots. + } + } + createdTmpDirs.clear(); +}); + +describe("relaunchGatewayScheduledTask", () => { + it("writes a detached schtasks relaunch helper", () => { + const unref = vi.fn(); + let seenCommandArg = ""; + spawnMock.mockImplementation((_file: string, args: string[]) => { + seenCommandArg = args[3]; + createdScriptPaths.add(decodeCmdPathArg(args[3])); + return { unref }; + }); + + const result = relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" }); + + expect(result).toMatchObject({ + ok: true, + method: "schtasks", + tried: expect.arrayContaining(['schtasks /Run /TN "OpenClaw Gateway (work)"']), + }); + expect(result.tried).toContain(`cmd.exe /d /s /c ${seenCommandArg}`); + expect(spawnMock).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", expect.any(String)], + expect.objectContaining({ + detached: true, + stdio: "ignore", + windowsHide: true, + }), + ); + expect(unref).toHaveBeenCalledOnce(); + + const scriptPath = [...createdScriptPaths][0]; + expect(scriptPath).toBeTruthy(); + const script = fs.readFileSync(scriptPath, "utf8"); + expect(script).toContain("timeout /t 1 /nobreak >nul"); + expect(script).toContain('schtasks /Run /TN "OpenClaw Gateway (work)" >nul 2>&1'); + expect(script).toContain('del "%~f0" >nul 2>&1'); + }); + + it("prefers OPENCLAW_WINDOWS_TASK_NAME overrides", () => { + spawnMock.mockImplementation((_file: string, args: string[]) => { + createdScriptPaths.add(decodeCmdPathArg(args[3])); + return { unref: vi.fn() }; + }); + + relaunchGatewayScheduledTask({ + OPENCLAW_PROFILE: "work", + OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (custom)", + }); + + const scriptPath = [...createdScriptPaths][0]; + const script = fs.readFileSync(scriptPath, "utf8"); + expect(script).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)" >nul 2>&1'); + }); + + it("returns failed when the helper cannot be spawned", () => { + spawnMock.mockImplementation(() => { + throw new Error("spawn failed"); + }); + + const result = relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" }); + + expect(result.ok).toBe(false); + expect(result.method).toBe("schtasks"); + expect(result.detail).toContain("spawn failed"); + }); + + it("quotes the cmd /c script path when temp paths contain metacharacters", () => { + const unref = vi.fn(); + const metacharTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw&(restart)-")); + createdTmpDirs.add(metacharTmpDir); + resolvePreferredOpenClawTmpDirMock.mockReturnValue(metacharTmpDir); + spawnMock.mockReturnValue({ unref }); + + relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" }); + + expect(spawnMock).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", expect.stringMatching(/^".*&.*"$/)], + expect.any(Object), + ); + }); +}); diff --git a/src/infra/windows-task-restart.ts b/src/infra/windows-task-restart.ts new file mode 100644 index 00000000000..147a88bac41 --- /dev/null +++ b/src/infra/windows-task-restart.ts @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { quoteCmdScriptArg } from "../daemon/cmd-argv.js"; +import { resolveGatewayWindowsTaskName } from "../daemon/constants.js"; +import type { RestartAttempt } from "./restart.js"; +import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; + +const TASK_RESTART_RETRY_LIMIT = 12; +const TASK_RESTART_RETRY_DELAY_SEC = 1; + +function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string { + const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); + if (override) { + return override; + } + return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); +} + +function buildScheduledTaskRestartScript(taskName: string): string { + const quotedTaskName = quoteCmdScriptArg(taskName); + return [ + "@echo off", + "setlocal", + "set /a attempts=0", + ":retry", + `timeout /t ${TASK_RESTART_RETRY_DELAY_SEC} /nobreak >nul`, + "set /a attempts+=1", + `schtasks /Run /TN ${quotedTaskName} >nul 2>&1`, + "if not errorlevel 1 goto cleanup", + `if %attempts% GEQ ${TASK_RESTART_RETRY_LIMIT} goto cleanup`, + "goto retry", + ":cleanup", + 'del "%~f0" >nul 2>&1', + ].join("\r\n"); +} + +export function relaunchGatewayScheduledTask(env: NodeJS.ProcessEnv = process.env): RestartAttempt { + const taskName = resolveWindowsTaskName(env); + const scriptPath = path.join( + resolvePreferredOpenClawTmpDir(), + `openclaw-schtasks-restart-${randomUUID()}.cmd`, + ); + const quotedScriptPath = quoteCmdScriptArg(scriptPath); + try { + fs.writeFileSync(scriptPath, `${buildScheduledTaskRestartScript(taskName)}\r\n`, "utf8"); + const child = spawn("cmd.exe", ["/d", "/s", "/c", quotedScriptPath], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + return { + ok: true, + method: "schtasks", + tried: [`schtasks /Run /TN "${taskName}"`, `cmd.exe /d /s /c ${quotedScriptPath}`], + }; + } catch (err) { + try { + fs.unlinkSync(scriptPath); + } catch { + // Best-effort cleanup; keep the original restart failure. + } + return { + ok: false, + method: "schtasks", + detail: err instanceof Error ? err.message : String(err), + tried: [`schtasks /Run /TN "${taskName}"`], + }; + } +}