From 6d26609f7baf44d64eef54df36991c8fd31f25fd Mon Sep 17 00:00:00 2001 From: Andy <91510251+andyk-ms@users.noreply.github.com> Date: Mon, 11 May 2026 03:32:48 -0700 Subject: [PATCH] fix(windows): prevent restart race from duplicate schtasks /Run (#52487) Prevent duplicate scheduled-task /Run attempts during Windows gateway restart by checking the task state before retrying. Co-authored-by: Andy K --- CHANGELOG.md | 1 + src/infra/windows-task-restart.test.ts | 23 +++++++++++++++++++++++ src/infra/windows-task-restart.ts | 11 +++++++++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55dc274afce..db5023e6e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -320,6 +320,7 @@ Docs: https://docs.openclaw.ai - Process tool: show input-wait hints from `log` and `poll` for idle interactive background sessions so operators can inspect stuck CLIs and resume them with existing input actions. Fixes #33957. Thanks @bitloi and @vincentkoc. - Shell env/Windows: hide the login-shell environment probe child window so gateway startup and shell-env refreshes do not flash a console on Windows. Fixes #78159. (#78266) Thanks @BradGroux. - MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13. +- Windows/restart: skip duplicate scheduled-task `/Run` calls when the gateway task is already running, using a locale-stable PowerShell task-state probe before retrying. Fixes #52044. (#52487) Thanks @andyk-ms. - Media/host-read: allow buffer-verified ZIP archives in the host-local media validator so agents can send ZIP attachments via the message tool. Fixes #78057. (#78292) Thanks @Linux2010. - Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq. - Contributor PRs: remind external contributors to redact private information like IP addresses, API keys, phone numbers, and non-public endpoints from real behavior proof. Thanks @pashpashpash. diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts index 5f3e7457bc9..1c5543b5968 100644 --- a/src/infra/windows-task-restart.test.ts +++ b/src/infra/windows-task-restart.test.ts @@ -120,7 +120,13 @@ describe("relaunchGatewayScheduledTask", () => { expect(script).toContain( 'openclaw restart attempt source=windows-task-handoff target="OpenClaw Gateway (work)"', ); + expect(script).toContain( + `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "(Get-ScheduledTask -TaskName 'OpenClaw Gateway (work)' -ErrorAction SilentlyContinue).State" 2>nul | findstr /I /C:"Running" >nul 2>&1`, + ); expect(script).toContain('schtasks /Run /TN "OpenClaw Gateway (work)" >>'); + expect(script.indexOf("powershell.exe -NoProfile")).toBeLessThan( + script.indexOf('schtasks /Run /TN "OpenClaw Gateway (work)"'), + ); expect(script).toContain('del "%~f0" >nul 2>&1'); }); @@ -140,6 +146,23 @@ describe("relaunchGatewayScheduledTask", () => { expect(script).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)" >>'); }); + it("escapes custom task names in the PowerShell running-task probe", () => { + spawnMock.mockImplementation((_file: string, args: string[]) => { + createdScriptPaths.add(decodeCmdPathArg(args[3])); + return { unref: vi.fn() }; + }); + + relaunchGatewayScheduledTask({ + OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (Bob's work)", + }); + + const scriptPath = [...createdScriptPaths][0]; + const script = fs.readFileSync(scriptPath, "utf8"); + expect(script).toContain( + "-Command \"(Get-ScheduledTask -TaskName 'OpenClaw Gateway (Bob''s work)' -ErrorAction SilentlyContinue).State\"", + ); + }); + it("returns failed when the helper cannot be spawned", () => { spawnMock.mockImplementation(() => { throw new Error("spawn failed"); diff --git a/src/infra/windows-task-restart.ts b/src/infra/windows-task-restart.ts index 8a1db004c49..2a6375d0727 100644 --- a/src/infra/windows-task-restart.ts +++ b/src/infra/windows-task-restart.ts @@ -13,6 +13,10 @@ import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; const TASK_RESTART_RETRY_LIMIT = 12; const TASK_RESTART_RETRY_DELAY_SEC = 1; +function quotePowerShellSingleQuotedLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string { const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); if (override) { @@ -29,6 +33,10 @@ function buildScheduledTaskRestartScript(params: { }): string { const { quotedLogPath, setupLines, taskName, taskScriptPath } = params; const quotedTaskName = quoteCmdScriptArg(taskName); + const queryTaskStateCommand = `(Get-ScheduledTask -TaskName ${quotePowerShellSingleQuotedLiteral( + taskName, + )} -ErrorAction SilentlyContinue).State`; + const quotedQueryTaskStateCommand = quoteCmdScriptArg(queryTaskStateCommand); const lines = [ "@echo off", "setlocal", @@ -40,6 +48,9 @@ function buildScheduledTaskRestartScript(params: { ":retry", `timeout /t ${TASK_RESTART_RETRY_DELAY_SEC} /nobreak >nul`, "set /a attempts+=1", + // Avoid racing with another restart path that already started the scheduled task. + `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ${quotedQueryTaskStateCommand} 2>nul | findstr /I /C:"Running" >nul 2>&1`, + "if not errorlevel 1 goto cleanup", `schtasks /Run /TN ${quotedTaskName} >> ${quotedLogPath} 2>&1`, "if not errorlevel 1 goto cleanup", `if %attempts% GEQ ${TASK_RESTART_RETRY_LIMIT} goto fallback`,