mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
133
src/infra/windows-task-restart.test.ts
Normal file
133
src/infra/windows-task-restart.test.ts
Normal file
@@ -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<string>();
|
||||
const createdTmpDirs = new Set<string>();
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
72
src/infra/windows-task-restart.ts
Normal file
72
src/infra/windows-task-restart.ts
Normal file
@@ -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}"`],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user