diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 2ebe9760ce96..b329b4ee8905 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -404,6 +404,16 @@ describe("runDaemonRestart health checks", () => { expect(waitParams.env?.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway-maintenance.service"); }); + it("carries launchd KeepAlive supervision into managed restart health", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + + await runDaemonRestart({ json: true }); + + expect(waitForGatewayHealthyRestart).toHaveBeenCalledWith( + expect.objectContaining({ supervisorKeepsAlive: true }), + ); + }); + it("re-reads the installed service environment after restart repair", async () => { service.readCommand .mockResolvedValueOnce({ diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index cbccf995ff63..05bef3199cd6 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -684,6 +684,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi delayMs: POST_RESTART_HEALTH_DELAY_MS, env: managedRestartContext.env, includeUnknownListenersAsStale: process.platform === "win32", + supervisorKeepsAlive: process.platform === "darwin", }); if (!health.healthy && health.staleGatewayPids.length > 0) { @@ -708,6 +709,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi delayMs: POST_RESTART_HEALTH_DELAY_MS, env: managedRestartContext.env, includeUnknownListenersAsStale: process.platform === "win32", + supervisorKeepsAlive: process.platform === "darwin", }); } diff --git a/src/cli/daemon-cli/restart-health-wait.test.ts b/src/cli/daemon-cli/restart-health-wait.test.ts index 6643d7844323..d7f1ff25003b 100644 --- a/src/cli/daemon-cli/restart-health-wait.test.ts +++ b/src/cli/daemon-cli/restart-health-wait.test.ts @@ -264,6 +264,53 @@ describe("restart health", () => { expect(sleep).toHaveBeenCalledTimes(25); }); + it("keeps waiting while a launchd KeepAlive supervisor can retry", async () => { + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + + const snapshot = await waitForStoppedFreeGatewayRestart({ supervisorKeepsAlive: true }); + + expect(snapshot.healthy).toBe(false); + expect(snapshot.runtime.status).toBe("stopped"); + expect(snapshot.portUsage.status).toBe("free"); + expect(snapshot.waitOutcome).toBe("timeout"); + expect(snapshot.elapsedMs).toBe(60_000); + expect(sleep).toHaveBeenCalledTimes(120); + }); + + it("accepts a launchd KeepAlive restart after the stopped-free grace window", async () => { + let runtimeReads = 0; + let portInspections = 0; + const service = { + readRuntime: vi.fn(async () => + ++runtimeReads >= 27 ? { status: "running", pid: 8000 } : { status: "stopped" }, + ), + } as unknown as GatewayService; + inspectPortUsage.mockImplementation(async () => + ++portInspections >= 27 + ? { + port: 18789, + status: "busy", + listeners: [{ pid: 8000, commandLine: "openclaw-gateway" }], + hints: [], + } + : { port: 18789, status: "free", listeners: [], hints: [] }, + ); + probeGateway.mockResolvedValue({ ok: true, close: null }); + + const { waitForGatewayHealthyRestart } = await import("./restart-health.js"); + const snapshot = await waitForGatewayHealthyRestart({ + service, + port: 18789, + attempts: 120, + delayMs: 500, + supervisorKeepsAlive: true, + }); + + expect(snapshot.healthy).toBe(true); + expect(snapshot.waitOutcome).toBe("healthy"); + expect(snapshot.elapsedMs).toBe(13_000); + }); + it("waits longer before stopped-free early exit on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32", configurable: true }); diff --git a/src/cli/daemon-cli/restart-health.test-helpers.ts b/src/cli/daemon-cli/restart-health.test-helpers.ts index 05aca795d1bc..70e7cc8567f3 100644 --- a/src/cli/daemon-cli/restart-health.test-helpers.ts +++ b/src/cli/daemon-cli/restart-health.test-helpers.ts @@ -156,7 +156,11 @@ export async function inspectAmbiguousOwnershipWithProbe( }); } -export async function waitForStoppedFreeGatewayRestart() { +export async function waitForStoppedFreeGatewayRestart( + params: { + supervisorKeepsAlive?: boolean; + } = {}, +) { const attempts = process.platform === "win32" ? 360 : 120; const service = makeGatewayService({ status: "stopped" }); inspectPortUsage.mockResolvedValue({ @@ -172,6 +176,7 @@ export async function waitForStoppedFreeGatewayRestart() { port: 18789, attempts, delayMs: 500, + supervisorKeepsAlive: params.supervisorKeepsAlive, }); } diff --git a/src/cli/daemon-cli/restart-health.ts b/src/cli/daemon-cli/restart-health.ts index c6ed04991e7a..34d9c8913a27 100644 --- a/src/cli/daemon-cli/restart-health.ts +++ b/src/cli/daemon-cli/restart-health.ts @@ -279,6 +279,7 @@ export async function waitForGatewayHealthyRestart(params: { expectedVersion?: string | null; includeUnknownListenersAsStale?: boolean; requireRunningService?: boolean; + supervisorKeepsAlive?: boolean; isStartupMigrationActive?: typeof hasActiveStartupMigrationLease; }): Promise { const startedAtMs = performance.now(); @@ -328,7 +329,12 @@ export async function waitForGatewayHealthyRestart(params: { if (snapshot.staleGatewayPids.length > 0 && snapshot.runtime.status !== "running") { return withWaitContext(snapshot, "stale-pids", elapsedMs); } - if (shouldEarlyExitStoppedFree(snapshot, attempt, minAttemptForEarlyExit)) { + // launchd KeepAlive can report a transient stopped state while its throttle window runs. + // Let the bounded standard deadline decide failure when the caller knows supervision persists. + if ( + !params.supervisorKeepsAlive && + shouldEarlyExitStoppedFree(snapshot, attempt, minAttemptForEarlyExit) + ) { consecutiveStoppedFreeCount += 1; if (consecutiveStoppedFreeCount >= STOPPED_FREE_THRESHOLD) { return withWaitContext(snapshot, "stopped-free", elapsedMs); diff --git a/src/cli/update-cli/update-command-service.test-support.ts b/src/cli/update-cli/update-command-service.test-support.ts index d44e0ffb5bb0..37d07863847e 100644 --- a/src/cli/update-cli/update-command-service.test-support.ts +++ b/src/cli/update-cli/update-command-service.test-support.ts @@ -51,6 +51,10 @@ type UpdateCommandServiceTestApi = { health: GatewayRestartSnapshot; launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null; }>; + hasLoadedLaunchdKeepAliveSupervisor(params: { + service: GatewayService; + env?: NodeJS.ProcessEnv; + }): Promise; shouldUseLegacyProcessRestartAfterUpdate(params: { updateMode: UpdateRunResult["mode"]; }): boolean; @@ -81,6 +85,12 @@ export async function recoverLaunchAgentAndRecheckGatewayHealth( return await getTestApi().recoverLaunchAgentAndRecheckGatewayHealth(params); } +export async function hasLoadedLaunchdKeepAliveSupervisor( + params: Parameters[0], +): Promise { + return await getTestApi().hasLoadedLaunchdKeepAliveSupervisor(params); +} + export function shouldUseLegacyProcessRestartAfterUpdate( params: Parameters[0], ): boolean { diff --git a/src/cli/update-cli/update-command-service.ts b/src/cli/update-cli/update-command-service.ts index 5b0a64c2e123..4d825f600a9f 100644 --- a/src/cli/update-cli/update-command-service.ts +++ b/src/cli/update-cli/update-command-service.ts @@ -202,10 +202,23 @@ async function recoverLaunchAgentAndRecheckGatewayHealth(params: { port: params.port, expectedVersion: params.expectedVersion, env: params.env, + supervisorKeepsAlive: true, }); return { health, launchAgentRecovery }; } +async function hasLoadedLaunchdKeepAliveSupervisor(params: { + service: GatewayService; + env?: NodeJS.ProcessEnv; +}): Promise { + if (process.platform !== "darwin") { + return false; + } + // OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before + // polling so an unloaded agent can still reach the existing recovery path promptly. + return await params.service.isLoaded({ env: params.env }).catch(() => false); +} + function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string { const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME); const installCommand = replaceCliName( @@ -248,6 +261,7 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { formatPostUpdateGatewayRecoveryInstructions, recoverInstalledLaunchAgentAfterUpdate, recoverLaunchAgentAndRecheckGatewayHealth, + hasLoadedLaunchdKeepAliveSupervisor, shouldUseLegacyProcessRestartAfterUpdate, }; } @@ -1206,12 +1220,17 @@ export async function maybeRestartService(params: { } }; const service = resolveGatewayService(); + let supervisorKeepsAlive = await hasLoadedLaunchdKeepAliveSupervisor({ + service, + env: params.serviceEnv, + }); let health = await waitForGatewayHealthyRestart({ service, port: params.gatewayPort, expectedVersion: expectedGatewayVersion, env: params.serviceEnv, requireRunningService: opts.requireRunningService, + supervisorKeepsAlive, }); if (!health.healthy && health.staleGatewayPids.length > 0) { if (!params.opts.json) { @@ -1223,12 +1242,17 @@ export async function maybeRestartService(params: { } await terminateStaleGatewayPids(health.staleGatewayPids); await restartAfterStaleCleanup(); + supervisorKeepsAlive = await hasLoadedLaunchdKeepAliveSupervisor({ + service, + env: params.serviceEnv, + }); health = await waitForGatewayHealthyRestart({ service, port: params.gatewayPort, expectedVersion: expectedGatewayVersion, env: params.serviceEnv, requireRunningService: opts.requireRunningService, + supervisorKeepsAlive, }); } diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 3be97d493e0a..55aca8ba8f25 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; +import type { GatewayService } from "../../daemon/service.js"; import type { UpdateRunResult } from "../../infra/update-runner.js"; import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js"; import { @@ -20,6 +21,7 @@ import { } from "./update-command-service.js"; import { formatPostUpdateGatewayRecoveryInstructions, + hasLoadedLaunchdKeepAliveSupervisor, recoverInstalledLaunchAgentAfterUpdate, recoverLaunchAgentAndRecheckGatewayHealth, shouldUseLegacyProcessRestartAfterUpdate, @@ -760,6 +762,7 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => { port: 18790, expectedVersion: "2026.5.3", env: { OPENCLAW_PROFILE: "stomme", OPENCLAW_PORT: "18790" }, + supervisorKeepsAlive: true, }); }); @@ -798,6 +801,36 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => { }); }); +describe("hasLoadedLaunchdKeepAliveSupervisor", () => { + it("requires a loaded LaunchAgent before extending restart health", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const isLoaded = vi.fn().mockResolvedValue(false); + const service = { isLoaded } as unknown as GatewayService; + + await expect( + hasLoadedLaunchdKeepAliveSupervisor({ service, env: { OPENCLAW_PROFILE: "work" } }), + ).resolves.toBe(false); + isLoaded.mockResolvedValue(true); + await expect(hasLoadedLaunchdKeepAliveSupervisor({ service })).resolves.toBe(true); + + platformSpy.mockRestore(); + }); + + it("does not inspect KeepAlive supervision outside macOS", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + const isLoaded = vi.fn().mockResolvedValue(true); + + await expect( + hasLoadedLaunchdKeepAliveSupervisor({ + service: { isLoaded } as unknown as GatewayService, + }), + ).resolves.toBe(false); + expect(isLoaded).not.toHaveBeenCalled(); + + platformSpy.mockRestore(); + }); +}); + describe("resolvePostCoreUpdateChildStdio", () => { it('returns "pipe" on Windows so the child never inherits the parent console handles', () => { // On Windows, stdio:"inherit" passes the parent's console HANDLE to the child process.