diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 4a18a797607..06ccfff5b9e 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -46,16 +46,17 @@ function clearSupervisorHints() { } } -function expectLaunchdKickstartSupervised(params?: { launchJobLabel?: string }) { +function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: string }) { setPlatform("darwin"); if (params?.launchJobLabel) { process.env.LAUNCH_JOB_LABEL = params.launchJobLabel; } process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway"; - triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" }); const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); - expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce(); + // launchd path no longer calls triggerOpenClawRestart — it relies on + // KeepAlive=true to restart the service after the caller exits. + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); } @@ -67,35 +68,19 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it("returns supervised when launchd hints are present on macOS", () => { + it("returns supervised when launchd hints are present on macOS (no kickstart)", () => { 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(); + // launchd relies on KeepAlive=true — no kickstart call needed. + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); }); - it("runs launchd kickstart helper on macOS when launchd label is set", () => { - expectLaunchdKickstartSupervised({ launchJobLabel: "ai.openclaw.gateway" }); - }); - - it("returns failed when launchd kickstart helper fails", () => { - setPlatform("darwin"); - process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; - process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway"; - triggerOpenClawRestartMock.mockReturnValue({ - ok: false, - method: "launchctl", - detail: "spawn failed", - }); - - const result = restartGatewayProcessWithFreshPid(); - - expect(result.mode).toBe("failed"); - expect(result.detail).toContain("spawn failed"); + it("returns supervised on macOS when launchd label is set (no kickstart)", () => { + expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" }); }); it("does not schedule kickstart on non-darwin platforms", () => { @@ -133,7 +118,7 @@ describe("restartGatewayProcessWithFreshPid", () => { it("returns supervised when OPENCLAW_LAUNCHD_LABEL is set (stock launchd plist)", () => { clearSupervisorHints(); - expectLaunchdKickstartSupervised(); + expectLaunchdSupervisedWithoutKickstart(); }); it("returns supervised when OPENCLAW_SYSTEMD_UNIT is set", () => { diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts index 0edc43f2de4..ad96133ebb6 100644 --- a/src/infra/process-respawn.ts +++ b/src/infra/process-respawn.ts @@ -30,7 +30,13 @@ export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult { } const supervisor = detectRespawnSupervisor(process.env); if (supervisor) { - if (supervisor === "launchd" || supervisor === "schtasks") { + // launchd: exit(0) is sufficient — KeepAlive=true restarts the service. + // Calling `kickstart -k` from within the service itself races with + // launchd's async bootout state machine: the spawnSync timeout kills the + // launchctl child, but launchd continues the bootout and eventually + // SIGKILLs this process, leaving the LaunchAgent permanently unloaded. + // See: https://github.com/openclaw/openclaw/issues/39760 + if (supervisor === "schtasks") { const restart = triggerOpenClawRestart(); if (!restart.ok) { return {