Skip to content

Commit 5bcb017

Browse files
committed
fix: restart Windows gateway via Scheduled Task
1 parent 26c9796 commit 5bcb017

File tree

9 files changed

+297
-52
lines changed

9 files changed

+297
-52
lines changed

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/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: 37 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,53 @@ 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");
148181
expect(spawnMock).not.toHaveBeenCalled();
149182
});
150183

151184
it("returns failed when spawn throws", () => {
152185
delete process.env.OPENCLAW_NO_RESPAWN;
153186
clearSupervisorHints();
187+
setPlatform("linux");
154188

155189
spawnMock.mockImplementation(() => {
156190
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: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,56 @@
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 = [
15+
"OPENCLAW_WINDOWS_TASK_NAME",
16+
"OPENCLAW_TASK_SCRIPT",
17+
"OPENCLAW_TASK_SCRIPT_NAME",
18+
] as const;
19+
20+
export const SUPERVISOR_HINT_ENV_VARS = [
21+
...LAUNCHD_SUPERVISOR_HINT_ENV_VARS,
22+
...SYSTEMD_SUPERVISOR_HINT_ENV_VARS,
23+
...WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS,
24+
"OPENCLAW_SERVICE_MARKER",
25+
"OPENCLAW_SERVICE_KIND",
26+
] as const;
27+
28+
export type RespawnSupervisor = "launchd" | "systemd" | "schtasks";
29+
30+
function hasAnyHint(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
31+
return keys.some((key) => {
1732
const value = env[key];
1833
return typeof value === "string" && value.trim().length > 0;
1934
});
2035
}
36+
37+
export function detectRespawnSupervisor(
38+
env: NodeJS.ProcessEnv = process.env,
39+
platform: NodeJS.Platform = process.platform,
40+
): RespawnSupervisor | null {
41+
if (platform === "darwin") {
42+
return hasAnyHint(env, LAUNCHD_SUPERVISOR_HINT_ENV_VARS) ? "launchd" : null;
43+
}
44+
if (platform === "linux") {
45+
return hasAnyHint(env, SYSTEMD_SUPERVISOR_HINT_ENV_VARS) ? "systemd" : null;
46+
}
47+
if (platform === "win32") {
48+
if (hasAnyHint(env, WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS)) {
49+
return "schtasks";
50+
}
51+
const marker = env.OPENCLAW_SERVICE_MARKER?.trim();
52+
const serviceKind = env.OPENCLAW_SERVICE_KIND?.trim();
53+
return marker && serviceKind === "gateway" ? "schtasks" : null;
54+
}
55+
return null;
56+
}

0 commit comments

Comments
 (0)