diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3de6017c4..943dc80d273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496. - Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc. +- macOS Gateway: tolerate launchctl bootstrap's already-loaded exit during restart fallback and use non-killing kickstart after bootstrap, avoiding a second race that can unload the LaunchAgent. Fixes #41934. Thanks @zerone0x. - TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris. - WhatsApp/TTS: preserve `audioAsVoice` through shared media payload sends and the WhatsApp outbound adapter, so `[[audio_as_voice]]` reply payloads keep their voice-note intent when routed through `sendPayload`. Fixes #66053. Thanks @masatohoshino. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. diff --git a/src/infra/restart.test.ts b/src/infra/restart.test.ts index 0c05b20f002..9455b365084 100644 --- a/src/infra/restart.test.ts +++ b/src/infra/restart.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureFullEnv } from "../test-utils/env.js"; const spawnSyncMock = vi.hoisted(() => vi.fn()); const resolveLsofCommandSyncMock = vi.hoisted(() => vi.fn()); @@ -25,12 +26,16 @@ vi.mock("../config/paths.js", async () => { let __testing: typeof import("./restart-stale-pids.js").__testing; let cleanStaleGatewayProcessesSync: typeof import("./restart-stale-pids.js").cleanStaleGatewayProcessesSync; let findGatewayPidsOnPortSync: typeof import("./restart-stale-pids.js").findGatewayPidsOnPortSync; +let triggerOpenClawRestart: typeof import("./restart.js").triggerOpenClawRestart; let currentTimeMs = 0; +const envSnapshot = captureFullEnv(); +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); beforeAll(async () => { ({ __testing, cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } = await import("./restart-stale-pids.js")); + ({ triggerOpenClawRestart } = await import("./restart.js")); }); beforeEach(() => { @@ -48,11 +53,25 @@ beforeEach(() => { }); afterEach(() => { + envSnapshot.restore(); __testing.setSleepSyncOverride(null); __testing.setDateNowOverride(null); + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } vi.restoreAllMocks(); }); +function setPlatform(platform: NodeJS.Platform): void { + if (!originalPlatformDescriptor) { + return; + } + Object.defineProperty(process, "platform", { + ...originalPlatformDescriptor, + value: platform, + }); +} + describe.runIf(process.platform !== "win32")("findGatewayPidsOnPortSync", () => { it("parses lsof output and filters non-openclaw/current processes", () => { const gatewayPidA = process.pid + 1000; @@ -164,3 +183,41 @@ describe.runIf(process.platform !== "win32")("cleanStaleGatewayProcessesSync", ( expect(killSpy).not.toHaveBeenCalled(); }); }); + +describe("triggerOpenClawRestart", () => { + it("continues when launchctl bootstrap reports the service is already loaded", () => { + setPlatform("darwin"); + delete process.env.VITEST; + delete process.env.NODE_ENV; + process.env.HOME = "/Users/test"; + process.env.OPENCLAW_PROFILE = "default"; + const uid = typeof process.getuid === "function" ? process.getuid() : 501; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (command === "/usr/sbin/lsof") { + return { error: undefined, status: 1, stdout: "" }; + } + if (command === "launchctl" && args[0] === "kickstart" && args[1] === "-k") { + return { error: undefined, status: 113, stderr: "service not loaded" }; + } + if (command === "launchctl" && args[0] === "bootstrap") { + return { error: undefined, status: 37, stderr: "Operation already in progress" }; + } + if (command === "launchctl" && args[0] === "kickstart") { + return { error: undefined, status: 0, stdout: "" }; + } + return { error: undefined, status: 1, stdout: "" }; + }); + + const result = triggerOpenClawRestart(); + + expect(result).toEqual({ + ok: true, + method: "launchctl", + tried: [ + `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`, + `launchctl bootstrap gui/${uid} /Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist`, + `launchctl kickstart gui/${uid}/ai.openclaw.gateway`, + ], + }); + }); +}); diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 34e3e969ce5..fa80eb211e0 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -20,6 +20,7 @@ const DEFAULT_DEFERRAL_POLL_MS = 500; // Configurable via gateway.reload.deferralTimeoutMs. const DEFAULT_DEFERRAL_MAX_WAIT_MS = 300_000; const RESTART_COOLDOWN_MS = 30_000; +const LAUNCHCTL_ALREADY_LOADED_EXIT_CODE = 37; const restartLog = createSubsystemLogger("restart"); @@ -427,7 +428,12 @@ export function triggerOpenClawRestart(): RestartAttempt { encoding: "utf8", timeout: SPAWN_TIMEOUT_MS, }); - if (boot.error || (boot.status !== 0 && boot.status !== null)) { + if ( + boot.error || + (boot.status !== 0 && + boot.status !== LAUNCHCTL_ALREADY_LOADED_EXIT_CODE && + boot.status !== null) + ) { return { ok: false, method: "launchctl", @@ -435,7 +441,7 @@ export function triggerOpenClawRestart(): RestartAttempt { tried, }; } - const retryArgs = ["kickstart", "-k", target]; + const retryArgs = ["kickstart", target]; tried.push(`launchctl ${retryArgs.join(" ")}`); const retry = spawnSync("launchctl", retryArgs, { encoding: "utf8",