From 3c0fd3dffe67759f60685a6fb1b016f0d6f5f3cd Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Thu, 12 Mar 2026 00:11:21 +0100 Subject: [PATCH] fix(daemon): replace bootout with kickstart -k for launchd restarts on macOS On macOS, launchctl bootout permanently unloads the LaunchAgent plist. Even with KeepAlive: true, launchd cannot respawn a service whose plist has been removed from its registry. This left users with a dead gateway requiring manual 'openclaw gateway install' to recover. Affected trigger paths: - openclaw gateway restart from an agent session (#43311) - SIGTERM on config reload (#43406) - Gateway self-restart via SIGTERM (#43035) - Hot reload on channel config change (#43049) Switch restartLaunchAgent() to launchctl kickstart -k, which force-kills and restarts the service without unloading the plist. When the restart originates from inside the launchd-managed process tree, delegate to a new detached handoff helper (launchd-restart-handoff.ts) to avoid the caller being killed mid-command. Self-restart paths in process-respawn.ts now schedule the detached start-after-exit handoff before exiting instead of relying on exit/KeepAlive timing. Fixes #43311, #43406, #43035, #43049 --- CHANGELOG.md | 1 + src/daemon/launchd-restart-handoff.ts | 142 ++++++++++++++++++++++++++ src/daemon/launchd.test.ts | 104 +++++++++++-------- src/daemon/launchd.ts | 89 ++++++++-------- src/infra/process-respawn.test.ts | 37 +++++++ src/infra/process-respawn.ts | 24 ++++- 6 files changed, 303 insertions(+), 94 deletions(-) create mode 100644 src/daemon/launchd-restart-handoff.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 623dba11d7d..ecb573d52a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus. - Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung. - Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode. +- Gateway/macOS launchd restarts: keep the LaunchAgent registered during explicit restarts, hand off self-restarts through a detached launchd helper, and recover config/hot reload restart paths without unloading the service. Fixes #43311, #43406, #43035, and #43049. - Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant. - Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant. - Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf. diff --git a/src/daemon/launchd-restart-handoff.ts b/src/daemon/launchd-restart-handoff.ts new file mode 100644 index 00000000000..1d04c3aea84 --- /dev/null +++ b/src/daemon/launchd-restart-handoff.ts @@ -0,0 +1,142 @@ +import { spawn } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { resolveGatewayLaunchAgentLabel } from "./constants.js"; + +export type LaunchdRestartHandoffMode = "kickstart" | "start-after-exit"; + +export type LaunchdRestartHandoffResult = { + ok: boolean; + pid?: number; + detail?: string; +}; + +export type LaunchdRestartTarget = { + domain: string; + label: string; + plistPath: string; + serviceTarget: string; +}; + +function resolveGuiDomain(): string { + if (typeof process.getuid !== "function") { + return "gui/501"; + } + return `gui/${process.getuid()}`; +} + +function resolveLaunchAgentLabel(env?: Record): string { + const envLabel = env?.OPENCLAW_LAUNCHD_LABEL?.trim(); + if (envLabel) { + return envLabel; + } + return resolveGatewayLaunchAgentLabel(env?.OPENCLAW_PROFILE); +} + +export function resolveLaunchdRestartTarget( + env: Record = process.env, +): LaunchdRestartTarget { + const domain = resolveGuiDomain(); + const label = resolveLaunchAgentLabel(env); + const home = env.HOME?.trim() || os.homedir(); + const plistPath = path.join(home, "Library", "LaunchAgents", `${label}.plist`); + return { + domain, + label, + plistPath, + serviceTarget: `${domain}/${label}`, + }; +} + +export function isCurrentProcessLaunchdServiceLabel( + label: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const launchdLabel = + env.LAUNCH_JOB_LABEL?.trim() || env.LAUNCH_JOB_NAME?.trim() || env.XPC_SERVICE_NAME?.trim(); + if (launchdLabel) { + return launchdLabel === label; + } + const configuredLabel = env.OPENCLAW_LAUNCHD_LABEL?.trim(); + return Boolean(configuredLabel && configuredLabel === label); +} + +function buildLaunchdRestartScript(mode: LaunchdRestartHandoffMode): string { + if (mode === "kickstart") { + return `service_target="$1" +domain="$2" +plist_path="$3" +sleep 1 +if ! launchctl kickstart -k "$service_target" >/dev/null 2>&1; then + launchctl enable "$service_target" >/dev/null 2>&1 + if launchctl bootstrap "$domain" "$plist_path" >/dev/null 2>&1; then + launchctl kickstart -k "$service_target" >/dev/null 2>&1 || true + fi +fi +`; + } + + return `service_target="$1" +domain="$2" +plist_path="$3" +wait_pid="$4" +if [ -n "$wait_pid" ] && [ "$wait_pid" -gt 1 ] 2>/dev/null; then + attempts=0 + while kill -0 "$wait_pid" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 100 ]; then + break + fi + sleep 0.1 + done +else + sleep 1 +fi +if ! launchctl start "$service_target" >/dev/null 2>&1; then + launchctl enable "$service_target" >/dev/null 2>&1 + if launchctl bootstrap "$domain" "$plist_path" >/dev/null 2>&1; then + launchctl start "$service_target" >/dev/null 2>&1 || launchctl kickstart -k "$service_target" >/dev/null 2>&1 || true + else + launchctl kickstart -k "$service_target" >/dev/null 2>&1 || true + fi +fi +`; +} + +export function scheduleDetachedLaunchdRestartHandoff(params: { + env?: Record; + mode: LaunchdRestartHandoffMode; + waitForPid?: number; +}): LaunchdRestartHandoffResult { + const target = resolveLaunchdRestartTarget(params.env); + const waitForPid = + typeof params.waitForPid === "number" && Number.isFinite(params.waitForPid) + ? Math.floor(params.waitForPid) + : 0; + try { + const child = spawn( + "/bin/sh", + [ + "-c", + buildLaunchdRestartScript(params.mode), + "openclaw-launchd-restart-handoff", + target.serviceTarget, + target.domain, + target.plistPath, + String(waitForPid), + ], + { + detached: true, + stdio: "ignore", + env: { ...process.env, ...params.env }, + }, + ); + child.unref(); + return { ok: true, pid: child.pid ?? undefined }; + } catch (err) { + return { + ok: false, + detail: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 99e5e1f933e..c67fa0594d6 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -18,11 +18,17 @@ const state = vi.hoisted(() => ({ listOutput: "", printOutput: "", bootstrapError: "", + kickstartError: "", + kickstartFailuresRemaining: 0, dirs: new Set(), dirModes: new Map(), files: new Map(), fileModes: new Map(), })); +const launchdRestartHandoffState = vi.hoisted(() => ({ + isCurrentProcessLaunchdServiceLabel: vi.fn<(label: string) => boolean>(() => false), + scheduleDetachedLaunchdRestartHandoff: vi.fn((_params: unknown) => ({ ok: true, pid: 7331 })), +})); const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; function normalizeLaunchctlArgs(file: string, args: string[]): string[] { @@ -49,10 +55,21 @@ vi.mock("./exec-file.js", () => ({ if (call[0] === "bootstrap" && state.bootstrapError) { return { stdout: "", stderr: state.bootstrapError, code: 1 }; } + if (call[0] === "kickstart" && state.kickstartError && state.kickstartFailuresRemaining > 0) { + state.kickstartFailuresRemaining -= 1; + return { stdout: "", stderr: state.kickstartError, code: 1 }; + } return { stdout: "", stderr: "", code: 0 }; }), })); +vi.mock("./launchd-restart-handoff.js", () => ({ + isCurrentProcessLaunchdServiceLabel: (label: string) => + launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel(label), + scheduleDetachedLaunchdRestartHandoff: (params: unknown) => + launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff(params), +})); + vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); const wrapped = { @@ -109,10 +126,19 @@ beforeEach(() => { state.listOutput = ""; state.printOutput = ""; state.bootstrapError = ""; + state.kickstartError = ""; + state.kickstartFailuresRemaining = 0; state.dirs.clear(); state.dirModes.clear(); state.files.clear(); state.fileModes.clear(); + launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReset(); + launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(false); + launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset(); + launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReturnValue({ + ok: true, + pid: 7331, + }); vi.clearAllMocks(); }); @@ -304,19 +330,37 @@ describe("launchd install", () => { expect(state.fileModes.get(plistPath)).toBe(0o644); }); - it("restarts LaunchAgent with bootout-enable-bootstrap-kickstart order", async () => { + it("restarts LaunchAgent with kickstart and no bootout", async () => { const env = createDefaultLaunchdEnv(); await restartLaunchAgent({ env, stdout: new PassThrough(), }); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const label = "ai.openclaw.gateway"; + const serviceId = `${domain}/${label}`; + expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", serviceId]); + expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); + expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false); + }); + + it("falls back to bootstrap when kickstart cannot find the service", async () => { + const env = createDefaultLaunchdEnv(); + state.kickstartError = "Could not find service"; + state.kickstartFailuresRemaining = 1; + + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "ai.openclaw.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); const serviceId = `${domain}/${label}`; - const bootoutIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "bootout" && c[1] === serviceId, + const kickstartCalls = state.launchctlCalls.filter( + (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId, ); const enableIndex = state.launchctlCalls.findIndex( (c) => c[0] === "enable" && c[1] === serviceId, @@ -324,53 +368,27 @@ describe("launchd install", () => { const bootstrapIndex = state.launchctlCalls.findIndex( (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, ); - const kickstartIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId, - ); - expect(bootoutIndex).toBeGreaterThanOrEqual(0); + expect(kickstartCalls).toHaveLength(2); expect(enableIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); - expect(kickstartIndex).toBeGreaterThanOrEqual(0); - expect(bootoutIndex).toBeLessThan(enableIndex); - expect(enableIndex).toBeLessThan(bootstrapIndex); - expect(bootstrapIndex).toBeLessThan(kickstartIndex); + expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); }); - it("waits for previous launchd pid to exit before bootstrapping", async () => { + it("hands restart off to a detached helper when invoked from the current LaunchAgent", async () => { const env = createDefaultLaunchdEnv(); - state.printOutput = ["state = running", "pid = 4242"].join("\n"); - const killSpy = vi.spyOn(process, "kill"); - killSpy - .mockImplementationOnce(() => true) - .mockImplementationOnce(() => { - const err = new Error("no such process") as NodeJS.ErrnoException; - err.code = "ESRCH"; - throw err; - }); + launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(true); - vi.useFakeTimers(); - try { - const restartPromise = restartLaunchAgent({ - env, - stdout: new PassThrough(), - }); - await vi.advanceTimersByTimeAsync(250); - await restartPromise; - expect(killSpy).toHaveBeenCalledWith(4242, 0); - const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - const label = "ai.openclaw.gateway"; - const bootoutIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "bootout" && c[1] === `${domain}/${label}`, - ); - const bootstrapIndex = state.launchctlCalls.findIndex((c) => c[0] === "bootstrap"); - expect(bootoutIndex).toBeGreaterThanOrEqual(0); - expect(bootstrapIndex).toBeGreaterThanOrEqual(0); - expect(bootoutIndex).toBeLessThan(bootstrapIndex); - } finally { - vi.useRealTimers(); - killSpy.mockRestore(); - } + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith({ + env, + mode: "kickstart", + }); + expect(state.launchctlCalls).toEqual([]); }); it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 492eb2e4d6e..b842536fb58 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -12,6 +12,10 @@ import { buildLaunchAgentPlist as buildLaunchAgentPlistImpl, readLaunchAgentProgramArgumentsFromFile, } from "./launchd-plist.js"; +import { + isCurrentProcessLaunchdServiceLabel, + scheduleDetachedLaunchdRestartHandoff, +} from "./launchd-restart-handoff.js"; import { formatLine, toPosixPath, writeFormattedLines } from "./output.js"; import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; @@ -352,34 +356,6 @@ function isUnsupportedGuiDomain(detail: string): boolean { ); } -const RESTART_PID_WAIT_TIMEOUT_MS = 10_000; -const RESTART_PID_WAIT_INTERVAL_MS = 200; - -async function sleepMs(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function waitForPidExit(pid: number): Promise { - if (!Number.isFinite(pid) || pid <= 1) { - return; - } - const deadline = Date.now() + RESTART_PID_WAIT_TIMEOUT_MS; - while (Date.now() < deadline) { - try { - process.kill(pid, 0); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ESRCH" || code === "EPERM") { - return; - } - return; - } - await sleepMs(RESTART_PID_WAIT_INTERVAL_MS); - } -} - export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs): Promise { const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); @@ -476,24 +452,43 @@ export async function restartLaunchAgent({ const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env: serviceEnv }); const plistPath = resolveLaunchAgentPlistPath(serviceEnv); + const serviceTarget = `${domain}/${label}`; - const runtime = await execLaunchctl(["print", `${domain}/${label}`]); - const previousPid = - runtime.code === 0 - ? parseLaunchctlPrint(runtime.stdout || runtime.stderr || "").pid - : undefined; - - const stop = await execLaunchctl(["bootout", `${domain}/${label}`]); - if (stop.code !== 0 && !isLaunchctlNotLoaded(stop)) { - throw new Error(`launchctl bootout failed: ${stop.stderr || stop.stdout}`.trim()); - } - if (typeof previousPid === "number") { - await waitForPidExit(previousPid); + // Restart requests issued from inside the managed gateway process tree need a + // detached handoff. A direct `kickstart -k` would terminate the caller before + // it can finish the restart command. + if (isCurrentProcessLaunchdServiceLabel(label)) { + const handoff = scheduleDetachedLaunchdRestartHandoff({ + env: serviceEnv, + mode: "kickstart", + }); + if (!handoff.ok) { + throw new Error(`launchd restart handoff failed: ${handoff.detail ?? "unknown error"}`); + } + try { + stdout.write(`${formatLine("Scheduled LaunchAgent restart", serviceTarget)}\n`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") { + throw err; + } + } + return; } - // launchd can persist "disabled" state after bootout; clear it before bootstrap - // (matches the same guard in installLaunchAgent). - await execLaunchctl(["enable", `${domain}/${label}`]); + const start = await execLaunchctl(["kickstart", "-k", serviceTarget]); + if (start.code === 0) { + try { + stdout.write(`${formatLine("Restarted LaunchAgent", serviceTarget)}\n`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") { + throw err; + } + } + return; + } + + // If the service was previously booted out, re-register the plist and retry. + await execLaunchctl(["enable", serviceTarget]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); if (boot.code !== 0) { const detail = (boot.stderr || boot.stdout).trim(); @@ -511,12 +506,12 @@ export async function restartLaunchAgent({ throw new Error(`launchctl bootstrap failed: ${detail}`); } - const start = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); - if (start.code !== 0) { - throw new Error(`launchctl kickstart failed: ${start.stderr || start.stdout}`.trim()); + const retry = await execLaunchctl(["kickstart", "-k", serviceTarget]); + if (retry.code !== 0) { + throw new Error(`launchctl kickstart failed: ${retry.stderr || retry.stdout}`.trim()); } try { - stdout.write(`${formatLine("Restarted LaunchAgent", `${domain}/${label}`)}\n`); + stdout.write(`${formatLine("Restarted LaunchAgent", serviceTarget)}\n`); } catch (err: unknown) { if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") { throw err; diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 7b9a9df1252..bacf4e1b24b 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -4,6 +4,7 @@ import { SUPERVISOR_HINT_ENV_VARS } from "./supervisor-markers.js"; const spawnMock = vi.hoisted(() => vi.fn()); const triggerOpenClawRestartMock = vi.hoisted(() => vi.fn()); +const scheduleDetachedLaunchdRestartHandoffMock = vi.hoisted(() => vi.fn()); vi.mock("node:child_process", () => ({ spawn: (...args: unknown[]) => spawnMock(...args), @@ -11,6 +12,10 @@ vi.mock("node:child_process", () => ({ vi.mock("./restart.js", () => ({ triggerOpenClawRestart: (...args: unknown[]) => triggerOpenClawRestartMock(...args), })); +vi.mock("../daemon/launchd-restart-handoff.js", () => ({ + scheduleDetachedLaunchdRestartHandoff: (...args: unknown[]) => + scheduleDetachedLaunchdRestartHandoffMock(...args), +})); import { restartGatewayProcessWithFreshPid } from "./process-respawn.js"; @@ -35,6 +40,8 @@ afterEach(() => { process.execArgv = [...originalExecArgv]; spawnMock.mockClear(); triggerOpenClawRestartMock.mockClear(); + scheduleDetachedLaunchdRestartHandoffMock.mockReset(); + scheduleDetachedLaunchdRestartHandoffMock.mockReturnValue({ ok: true, pid: 8123 }); if (originalPlatformDescriptor) { Object.defineProperty(process, "platform", originalPlatformDescriptor); } @@ -54,6 +61,11 @@ function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: str process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway"; const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); + expect(scheduleDetachedLaunchdRestartHandoffMock).toHaveBeenCalledWith({ + env: process.env, + mode: "start-after-exit", + waitForPid: process.pid, + }); expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); } @@ -72,6 +84,12 @@ describe("restartGatewayProcessWithFreshPid", () => { process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); + expect(result.detail).toContain("launchd restart handoff"); + expect(scheduleDetachedLaunchdRestartHandoffMock).toHaveBeenCalledWith({ + env: process.env, + mode: "start-after-exit", + waitForPid: process.pid, + }); expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); }); @@ -96,6 +114,25 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); }); + it("falls back to plain supervised exit when launchd handoff scheduling fails", () => { + clearSupervisorHints(); + setPlatform("darwin"); + process.env.XPC_SERVICE_NAME = "ai.openclaw.gateway"; + scheduleDetachedLaunchdRestartHandoffMock.mockReturnValue({ + ok: false, + detail: "spawn failed", + }); + + const result = restartGatewayProcessWithFreshPid(); + + expect(result).toEqual({ + mode: "supervised", + detail: "launchd exit fallback (spawn failed)", + }); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + it("does not schedule kickstart on non-darwin platforms", () => { setPlatform("linux"); process.env.INVOCATION_ID = "abc123"; diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts index 8bf1503b18f..473319f86fb 100644 --- a/src/infra/process-respawn.ts +++ b/src/infra/process-respawn.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { scheduleDetachedLaunchdRestartHandoff } from "../daemon/launchd-restart-handoff.js"; import { triggerOpenClawRestart } from "./restart.js"; import { detectRespawnSupervisor } from "./supervisor-markers.js"; @@ -30,10 +31,25 @@ export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult { } const supervisor = detectRespawnSupervisor(process.env); if (supervisor) { - // launchd: exit(0) is sufficient — KeepAlive=true restarts the service. - // Self-issued `kickstart -k` races with launchd's bootout state machine - // and can leave the LaunchAgent permanently unloaded. - // See: https://github.com/openclaw/openclaw/issues/39760 + // Hand off launchd restarts to a detached helper before exiting so config + // reloads and SIGUSR1-driven restarts do not depend on exit/respawn timing. + if (supervisor === "launchd") { + const handoff = scheduleDetachedLaunchdRestartHandoff({ + env: process.env, + mode: "start-after-exit", + waitForPid: process.pid, + }); + if (!handoff.ok) { + return { + mode: "supervised", + detail: `launchd exit fallback (${handoff.detail ?? "restart handoff failed"})`, + }; + } + return { + mode: "supervised", + detail: `launchd restart handoff pid ${handoff.pid ?? "unknown"}`, + }; + } if (supervisor === "schtasks") { const restart = triggerOpenClawRestart(); if (!restart.ok) {