Skip to content

Commit 05c240f

Browse files
authored
fix: restart Windows gateway via Scheduled Task (openclaw#38825) (openclaw#38825)
1 parent 26c9796 commit 05c240f

12 files changed

Lines changed: 371 additions & 54 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ Docs: https://docs.openclaw.ai
229229
- 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.
230230
- 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.
231231
- 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.
232+
- 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.
232233

233234
## 2026.3.2
234235

src/cli/gateway-cli/run-loop.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ export async function runGatewayLoop(params: {
7575
`full process restart failed (${respawn.detail ?? "unknown error"}); falling back to in-process restart`,
7676
);
7777
} else {
78-
gatewayLog.info("restart mode: in-process restart (OPENCLAW_NO_RESPAWN)");
78+
gatewayLog.info(
79+
`restart mode: in-process restart (${respawn.detail ?? "OPENCLAW_NO_RESPAWN"})`,
80+
);
7981
}
8082
if (hadLock && !(await reacquireLockForInProcessRestart())) {
8183
return;

src/cli/update-cli/restart-helper.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,25 @@ describe("restart-helper", () => {
298298

299299
await runRestartScript(scriptPath);
300300

301-
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/c", scriptPath], {
301+
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", scriptPath], {
302302
detached: true,
303303
stdio: "ignore",
304304
});
305305
expect(mockChild.unref).toHaveBeenCalled();
306306
});
307+
308+
it("quotes cmd.exe /c paths with metacharacters on Windows", async () => {
309+
Object.defineProperty(process, "platform", { value: "win32" });
310+
const scriptPath = "C:\\Temp\\me&(ow)\\fake-script.bat";
311+
const mockChild = { unref: vi.fn() };
312+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
313+
314+
await runRestartScript(scriptPath);
315+
316+
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", `"${scriptPath}"`], {
317+
detached: true,
318+
stdio: "ignore",
319+
});
320+
});
307321
});
308322
});

src/cli/update-cli/restart-helper.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
55
import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js";
6+
import { quoteCmdScriptArg } from "../../daemon/cmd-argv.js";
67
import {
78
resolveGatewayLaunchAgentLabel,
89
resolveGatewaySystemdServiceName,
@@ -161,7 +162,7 @@ del "%~f0"
161162
export async function runRestartScript(scriptPath: string): Promise<void> {
162163
const isWindows = process.platform === "win32";
163164
const file = isWindows ? "cmd.exe" : "/bin/sh";
164-
const args = isWindows ? ["/c", scriptPath] : [scriptPath];
165+
const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath];
165166

166167
const child = spawn(file, args, {
167168
detached: true,

src/daemon/service-env.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ describe("buildServiceEnvironment", () => {
278278
expect(env.OPENCLAW_SERVICE_KIND).toBe("gateway");
279279
expect(typeof env.OPENCLAW_SERVICE_VERSION).toBe("string");
280280
expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway.service");
281+
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway");
281282
if (process.platform === "darwin") {
282283
expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.gateway");
283284
}
@@ -305,6 +306,7 @@ describe("buildServiceEnvironment", () => {
305306
port: 18789,
306307
});
307308
expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway-work.service");
309+
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway (work)");
308310
if (process.platform === "darwin") {
309311
expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.work");
310312
}

src/daemon/service-env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
GATEWAY_SERVICE_MARKER,
77
resolveGatewayLaunchAgentLabel,
88
resolveGatewaySystemdServiceName,
9+
resolveGatewayWindowsTaskName,
910
NODE_SERVICE_KIND,
1011
NODE_SERVICE_MARKER,
1112
NODE_WINDOWS_TASK_SCRIPT_NAME,
@@ -262,6 +263,7 @@ export function buildServiceEnvironment(params: {
262263
OPENCLAW_GATEWAY_TOKEN: token,
263264
OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel,
264265
OPENCLAW_SYSTEMD_UNIT: systemdUnit,
266+
OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile),
265267
OPENCLAW_SERVICE_MARKER: GATEWAY_SERVICE_MARKER,
266268
OPENCLAW_SERVICE_KIND: GATEWAY_SERVICE_KIND,
267269
OPENCLAW_SERVICE_VERSION: VERSION,

src/infra/process-respawn.test.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,14 @@ describe("restartGatewayProcessWithFreshPid", () => {
6767
expect(spawnMock).not.toHaveBeenCalled();
6868
});
6969

70-
it("returns supervised when launchd/systemd hints are present", () => {
70+
it("returns supervised when launchd hints are present on macOS", () => {
7171
clearSupervisorHints();
72+
setPlatform("darwin");
7273
process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway";
74+
triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" });
7375
const result = restartGatewayProcessWithFreshPid();
7476
expect(result.mode).toBe("supervised");
77+
expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce();
7578
expect(spawnMock).not.toHaveBeenCalled();
7679
});
7780

@@ -110,6 +113,7 @@ describe("restartGatewayProcessWithFreshPid", () => {
110113
it("spawns detached child with current exec argv", () => {
111114
delete process.env.OPENCLAW_NO_RESPAWN;
112115
clearSupervisorHints();
116+
setPlatform("linux");
113117
process.execArgv = ["--import", "tsx"];
114118
process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"];
115119
spawnMock.mockReturnValue({ pid: 4242, unref: vi.fn() });
@@ -134,23 +138,68 @@ describe("restartGatewayProcessWithFreshPid", () => {
134138

135139
it("returns supervised when OPENCLAW_SYSTEMD_UNIT is set", () => {
136140
clearSupervisorHints();
141+
setPlatform("linux");
137142
process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway.service";
138143
const result = restartGatewayProcessWithFreshPid();
139144
expect(result.mode).toBe("supervised");
140145
expect(spawnMock).not.toHaveBeenCalled();
141146
});
142147

143-
it("returns supervised when OPENCLAW_SERVICE_MARKER is set", () => {
148+
it("returns supervised when OpenClaw gateway task markers are set on Windows", () => {
144149
clearSupervisorHints();
145-
process.env.OPENCLAW_SERVICE_MARKER = "gateway";
150+
setPlatform("win32");
151+
process.env.OPENCLAW_SERVICE_MARKER = "openclaw";
152+
process.env.OPENCLAW_SERVICE_KIND = "gateway";
153+
triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "schtasks" });
146154
const result = restartGatewayProcessWithFreshPid();
147155
expect(result.mode).toBe("supervised");
156+
expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce();
157+
expect(spawnMock).not.toHaveBeenCalled();
158+
});
159+
160+
it("keeps generic service markers out of non-Windows supervisor detection", () => {
161+
clearSupervisorHints();
162+
setPlatform("linux");
163+
process.env.OPENCLAW_SERVICE_MARKER = "openclaw";
164+
process.env.OPENCLAW_SERVICE_KIND = "gateway";
165+
spawnMock.mockReturnValue({ pid: 4242, unref: vi.fn() });
166+
167+
const result = restartGatewayProcessWithFreshPid();
168+
169+
expect(result).toEqual({ mode: "spawned", pid: 4242 });
170+
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
171+
});
172+
173+
it("returns disabled on Windows without Scheduled Task markers", () => {
174+
clearSupervisorHints();
175+
setPlatform("win32");
176+
177+
const result = restartGatewayProcessWithFreshPid();
178+
179+
expect(result.mode).toBe("disabled");
180+
expect(result.detail).toContain("Scheduled Task");
181+
expect(spawnMock).not.toHaveBeenCalled();
182+
});
183+
184+
it("ignores node task script hints for gateway restart detection on Windows", () => {
185+
clearSupervisorHints();
186+
setPlatform("win32");
187+
process.env.OPENCLAW_TASK_SCRIPT = "C:\\openclaw\\node.cmd";
188+
process.env.OPENCLAW_TASK_SCRIPT_NAME = "node.cmd";
189+
process.env.OPENCLAW_SERVICE_MARKER = "openclaw";
190+
process.env.OPENCLAW_SERVICE_KIND = "node";
191+
192+
const result = restartGatewayProcessWithFreshPid();
193+
194+
expect(result.mode).toBe("disabled");
195+
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
148196
expect(spawnMock).not.toHaveBeenCalled();
149197
});
150198

151199
it("returns failed when spawn throws", () => {
152200
delete process.env.OPENCLAW_NO_RESPAWN;
153201
clearSupervisorHints();
202+
setPlatform("linux");
154203

155204
spawnMock.mockImplementation(() => {
156205
throw new Error("spawn failed");

src/infra/process-respawn.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { spawn } from "node:child_process";
22
import { triggerOpenClawRestart } from "./restart.js";
3-
import { hasSupervisorHint } from "./supervisor-markers.js";
3+
import { detectRespawnSupervisor } from "./supervisor-markers.js";
44

55
type RespawnMode = "spawned" | "supervised" | "disabled" | "failed";
66

@@ -18,34 +18,37 @@ function isTruthy(value: string | undefined): boolean {
1818
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1919
}
2020

21-
function isLikelySupervisedProcess(env: NodeJS.ProcessEnv = process.env): boolean {
22-
return hasSupervisorHint(env);
23-
}
24-
2521
/**
2622
* Attempt to restart this process with a fresh PID.
27-
* - supervised environments (launchd/systemd): caller should exit and let supervisor restart
23+
* - supervised environments (launchd/systemd/schtasks): caller should exit and let supervisor restart
2824
* - OPENCLAW_NO_RESPAWN=1: caller should keep in-process restart behavior (tests/dev)
2925
* - otherwise: spawn detached child with current argv/execArgv, then caller exits
3026
*/
3127
export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult {
3228
if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) {
3329
return { mode: "disabled" };
3430
}
35-
if (isLikelySupervisedProcess(process.env)) {
36-
// On macOS under launchd, actively kickstart the supervised service to
37-
// bypass ThrottleInterval delays for intentional restarts.
38-
if (process.platform === "darwin" && process.env.OPENCLAW_LAUNCHD_LABEL?.trim()) {
31+
const supervisor = detectRespawnSupervisor(process.env);
32+
if (supervisor) {
33+
if (supervisor === "launchd" || supervisor === "schtasks") {
3934
const restart = triggerOpenClawRestart();
4035
if (!restart.ok) {
4136
return {
4237
mode: "failed",
43-
detail: restart.detail ?? "launchctl kickstart failed",
38+
detail: restart.detail ?? `${restart.method} restart failed`,
4439
};
4540
}
4641
}
4742
return { mode: "supervised" };
4843
}
44+
if (process.platform === "win32") {
45+
// Detached respawn is unsafe on Windows without an identified Scheduled Task:
46+
// the child becomes orphaned if the original process exits.
47+
return {
48+
mode: "disabled",
49+
detail: "win32: detached respawn unsupported without Scheduled Task markers",
50+
};
51+
}
4952

5053
try {
5154
const args = [...process.execArgv, ...process.argv.slice(1)];

src/infra/restart.ts

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import {
77
} from "../daemon/constants.js";
88
import { createSubsystemLogger } from "../logging/subsystem.js";
99
import { cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } from "./restart-stale-pids.js";
10+
import { relaunchGatewayScheduledTask } from "./windows-task-restart.js";
1011

1112
export type RestartAttempt = {
1213
ok: boolean;
13-
method: "launchctl" | "systemd" | "supervisor";
14+
method: "launchctl" | "systemd" | "schtasks" | "supervisor";
1415
detail?: string;
1516
tried?: string[];
1617
};
@@ -296,36 +297,41 @@ export function triggerOpenClawRestart(): RestartAttempt {
296297
cleanStaleGatewayProcessesSync();
297298

298299
const tried: string[] = [];
299-
if (process.platform !== "darwin") {
300-
if (process.platform === "linux") {
301-
const unit = normalizeSystemdUnit(
302-
process.env.OPENCLAW_SYSTEMD_UNIT,
303-
process.env.OPENCLAW_PROFILE,
304-
);
305-
const userArgs = ["--user", "restart", unit];
306-
tried.push(`systemctl ${userArgs.join(" ")}`);
307-
const userRestart = spawnSync("systemctl", userArgs, {
308-
encoding: "utf8",
309-
timeout: SPAWN_TIMEOUT_MS,
310-
});
311-
if (!userRestart.error && userRestart.status === 0) {
312-
return { ok: true, method: "systemd", tried };
313-
}
314-
const systemArgs = ["restart", unit];
315-
tried.push(`systemctl ${systemArgs.join(" ")}`);
316-
const systemRestart = spawnSync("systemctl", systemArgs, {
317-
encoding: "utf8",
318-
timeout: SPAWN_TIMEOUT_MS,
319-
});
320-
if (!systemRestart.error && systemRestart.status === 0) {
321-
return { ok: true, method: "systemd", tried };
322-
}
323-
const detail = [
324-
`user: ${formatSpawnDetail(userRestart)}`,
325-
`system: ${formatSpawnDetail(systemRestart)}`,
326-
].join("; ");
327-
return { ok: false, method: "systemd", detail, tried };
300+
if (process.platform === "linux") {
301+
const unit = normalizeSystemdUnit(
302+
process.env.OPENCLAW_SYSTEMD_UNIT,
303+
process.env.OPENCLAW_PROFILE,
304+
);
305+
const userArgs = ["--user", "restart", unit];
306+
tried.push(`systemctl ${userArgs.join(" ")}`);
307+
const userRestart = spawnSync("systemctl", userArgs, {
308+
encoding: "utf8",
309+
timeout: SPAWN_TIMEOUT_MS,
310+
});
311+
if (!userRestart.error && userRestart.status === 0) {
312+
return { ok: true, method: "systemd", tried };
313+
}
314+
const systemArgs = ["restart", unit];
315+
tried.push(`systemctl ${systemArgs.join(" ")}`);
316+
const systemRestart = spawnSync("systemctl", systemArgs, {
317+
encoding: "utf8",
318+
timeout: SPAWN_TIMEOUT_MS,
319+
});
320+
if (!systemRestart.error && systemRestart.status === 0) {
321+
return { ok: true, method: "systemd", tried };
328322
}
323+
const detail = [
324+
`user: ${formatSpawnDetail(userRestart)}`,
325+
`system: ${formatSpawnDetail(systemRestart)}`,
326+
].join("; ");
327+
return { ok: false, method: "systemd", detail, tried };
328+
}
329+
330+
if (process.platform === "win32") {
331+
return relaunchGatewayScheduledTask(process.env);
332+
}
333+
334+
if (process.platform !== "darwin") {
329335
return {
330336
ok: false,
331337
method: "supervisor",

src/infra/supervisor-markers.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,52 @@
1-
export const SUPERVISOR_HINT_ENV_VARS = [
2-
// macOS launchd
1+
const LAUNCHD_SUPERVISOR_HINT_ENV_VARS = [
32
"LAUNCH_JOB_LABEL",
43
"LAUNCH_JOB_NAME",
5-
// OpenClaw service env markers
64
"OPENCLAW_LAUNCHD_LABEL",
5+
] as const;
6+
7+
const SYSTEMD_SUPERVISOR_HINT_ENV_VARS = [
78
"OPENCLAW_SYSTEMD_UNIT",
8-
"OPENCLAW_SERVICE_MARKER",
9-
// Linux systemd
109
"INVOCATION_ID",
1110
"SYSTEMD_EXEC_PID",
1211
"JOURNAL_STREAM",
1312
] as const;
1413

15-
export function hasSupervisorHint(env: NodeJS.ProcessEnv = process.env): boolean {
16-
return SUPERVISOR_HINT_ENV_VARS.some((key) => {
14+
const WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS = ["OPENCLAW_WINDOWS_TASK_NAME"] as const;
15+
16+
export const SUPERVISOR_HINT_ENV_VARS = [
17+
...LAUNCHD_SUPERVISOR_HINT_ENV_VARS,
18+
...SYSTEMD_SUPERVISOR_HINT_ENV_VARS,
19+
...WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS,
20+
"OPENCLAW_SERVICE_MARKER",
21+
"OPENCLAW_SERVICE_KIND",
22+
] as const;
23+
24+
export type RespawnSupervisor = "launchd" | "systemd" | "schtasks";
25+
26+
function hasAnyHint(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
27+
return keys.some((key) => {
1728
const value = env[key];
1829
return typeof value === "string" && value.trim().length > 0;
1930
});
2031
}
32+
33+
export function detectRespawnSupervisor(
34+
env: NodeJS.ProcessEnv = process.env,
35+
platform: NodeJS.Platform = process.platform,
36+
): RespawnSupervisor | null {
37+
if (platform === "darwin") {
38+
return hasAnyHint(env, LAUNCHD_SUPERVISOR_HINT_ENV_VARS) ? "launchd" : null;
39+
}
40+
if (platform === "linux") {
41+
return hasAnyHint(env, SYSTEMD_SUPERVISOR_HINT_ENV_VARS) ? "systemd" : null;
42+
}
43+
if (platform === "win32") {
44+
if (hasAnyHint(env, WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS)) {
45+
return "schtasks";
46+
}
47+
const marker = env.OPENCLAW_SERVICE_MARKER?.trim();
48+
const serviceKind = env.OPENCLAW_SERVICE_KIND?.trim();
49+
return marker && serviceKind === "gateway" ? "schtasks" : null;
50+
}
51+
return null;
52+
}

0 commit comments

Comments
 (0)