From 1f88cb2ce5dd02757e9aba4b1751014c25134e6f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 05:35:21 +0100 Subject: [PATCH] fix(gateway): persist macOS stop disable after bootout Summary: - carry forward #78412's macOS LaunchAgent bootout-by-default stop behavior and repair guard - fix the remaining `gateway stop --disable` tail when the service is already not loaded after bootout - add lifecycle regressions, docs, and changelog Verification: - pnpm install - pnpm test src/cli/daemon-cli/lifecycle-core.test.ts src/cli/daemon-cli/lifecycle.test.ts src/daemon/launchd.test.ts - pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/cli/daemon-cli/lifecycle-core.ts src/cli/daemon-cli/lifecycle.ts src/cli/daemon-cli/lifecycle-core.test.ts src/cli/daemon-cli/lifecycle.test.ts docs/cli/gateway.md docs/gateway/index.md src/daemon/launchd.ts src/daemon/launchd.test.ts src/cli/daemon-cli/register-service-commands.ts src/cli/daemon-cli/types.ts src/daemon/service-types.ts - git diff --check origin/main...HEAD - pnpm build - Parallels macOS Tahoe VM reproduce/fix proof in PR body - PR checks green: Real behavior proof, auto-response, dispatch, label, label-issues Co-authored-by: wdeveloper16 <25180374+wdeveloper16@users.noreply.github.com> --- CHANGELOG.md | 3 + docs/cli/gateway.md | 6 +- docs/gateway/index.md | 4 +- src/cli/daemon-cli/lifecycle-core.test.ts | 24 +++++ src/cli/daemon-cli/lifecycle-core.ts | 18 +++- src/cli/daemon-cli/lifecycle.test.ts | 15 ++- src/cli/daemon-cli/lifecycle.ts | 1 + .../daemon-cli/register-service-commands.ts | 5 + src/cli/daemon-cli/types.ts | 1 + src/daemon/launchd.test.ts | 99 +++++++++++++++---- src/daemon/launchd.ts | 36 +++++-- src/daemon/service-types.ts | 1 + 12 files changed, 182 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b992bd59c..27551655a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,6 +171,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/macOS: `openclaw gateway stop` now uses `launchctl bootout` by default instead of unconditionally calling `launchctl disable`, so KeepAlive auto-recovery still works after unexpected crashes; use the new `--disable` flag to opt into the persistent-disable behavior when a manual stop should survive reboots. Fixes #77934. Thanks @bmoran1022. +- Gateway/macOS: `repairLaunchAgentBootstrap` no longer kickstarts an already-running LaunchAgent, preventing unnecessary service restarts and session disconnects when repair runs against a healthy gateway. Fixes #77428. Thanks @ramitrkar-hash. +- Gateway/macOS: `openclaw gateway stop --disable` now persists the LaunchAgent disable bit even after a previous bootout left the service not loaded, keeping the explicit stay-down path reliable. (#78412) Thanks @wdeveloper16. - Control UI/chat: hide retired and non-public Google Gemini model IDs from chat model catalogs and route the bare `gemini-3-pro` alias to Gemini 3.1 Pro Preview instead of the shut-down Gemini 3 Pro Preview. Thanks @BunsDev. - CLI/install: refuse state-mutating OpenClaw CLI runs as root by default, keep an explicit `OPENCLAW_ALLOW_ROOT=1` escape hatch for intentional root/container use, and update DigitalOcean setup guidance to run OpenClaw as a non-root user. Fixes #67478. Thanks @Jerry-Xin and @natechicago. - Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index e2896d3a27b..0d3bf528065 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -483,11 +483,13 @@ openclaw gateway restart - `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `gateway install`: `--port`, `--runtime `, `--token`, `--wrapper `, `--force`, `--json` - `gateway restart`: `--safe`, `--force`, `--wait `, `--json` - - `gateway uninstall|start|stop`: `--json` + - `gateway uninstall|start`: `--json` + - `gateway stop`: `--disable`, `--json` - - Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute; on macOS, `gateway stop` intentionally disables the LaunchAgent before stopping it. + - Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute. + - On macOS, `gateway stop` uses `launchctl bootout` by default, which removes the LaunchAgent from the current boot session without persisting a disable — KeepAlive auto-recovery remains active for future crashes and `gateway start` re-enables cleanly without a manual `launchctl enable`. Pass `--disable` to persistently suppress KeepAlive and RunAtLoad so the gateway does not respawn until the next explicit `gateway start`; use this when a manual stop should survive reboots or system restarts. - `gateway restart --safe` asks the running Gateway to preflight active OpenClaw work and defer the restart until reply delivery, embedded runs, and task runs drain. `--safe` cannot be combined with `--force` or `--wait`. - `gateway restart --wait 30s` overrides the configured restart drain budget for that restart. Bare numbers are milliseconds; units such as `s`, `m`, and `h` are accepted. `--wait 0` waits indefinitely. - `gateway restart --force` skips the active-work drain and restarts immediately. Use it when an operator has already inspected the listed task blockers and wants the gateway back now. diff --git a/docs/gateway/index.md b/docs/gateway/index.md index b76a1b0f8f8..9894d3b93ca 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -217,7 +217,9 @@ openclaw gateway restart openclaw gateway stop ``` -Use `openclaw gateway restart` for restarts. Do not chain `openclaw gateway stop` and `openclaw gateway start`; on macOS, `gateway stop` intentionally disables the LaunchAgent before stopping it. +Use `openclaw gateway restart` for restarts. Do not chain `openclaw gateway stop` and `openclaw gateway start` as a restart substitute. + +On macOS, `gateway stop` uses `launchctl bootout` by default — this removes the LaunchAgent from the current boot session without persisting a disable, so KeepAlive auto-recovery still works after unexpected crashes and `gateway start` re-enables cleanly. To persistently suppress auto-respawn across reboots, pass `--disable`: `openclaw gateway stop --disable`. LaunchAgent labels are `ai.openclaw.gateway` (default) or `ai.openclaw.` (named profile). `openclaw doctor` audits and repairs service config drift. diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index d85235c156e..635e8838c75 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -216,6 +216,30 @@ describe("runServiceRestart token drift", () => { expect(service.stop).not.toHaveBeenCalled(); }); + it("runs a requested managed stop even when the service is not loaded", async () => { + const onNotLoaded = vi.fn(async () => ({ + result: "stopped" as const, + message: "Gateway stop signal sent to unmanaged process on port 18789: 4200.", + })); + service.isLoaded.mockResolvedValue(false); + + await runServiceStop({ + serviceNoun: "Gateway", + service, + opts: { json: true, disable: true }, + stopWhenNotLoaded: true, + onNotLoaded, + }); + + const payload = readJsonLog<{ result?: string; service?: { loaded?: boolean } }>(); + expect(payload.result).toBe("stopped"); + expect(payload.service?.loaded).toBe(false); + expect(service.stop).toHaveBeenCalledWith( + expect.objectContaining({ env: process.env, disable: true }), + ); + expect(onNotLoaded).not.toHaveBeenCalled(); + }); + it("emits started when a not-loaded start path repairs the service", async () => { service.isLoaded.mockResolvedValue(false); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index e5dad463c57..d8e7efae5a9 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -32,6 +32,7 @@ type DaemonLifecycleOptions = { force?: boolean; wait?: string; restartIntent?: GatewayRestartIntent; + disable?: boolean; }; type RestartPostCheckContext = { @@ -362,6 +363,7 @@ export async function runServiceStop(params: { service: GatewayService; opts?: DaemonLifecycleOptions; onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise; + stopWhenNotLoaded?: boolean; }) { const json = Boolean(params.opts?.json); const { stdout, emit, fail } = createDaemonActionContext({ action: "stop", json }); @@ -382,6 +384,20 @@ export async function runServiceStop(params: { } } if (!loaded) { + if (params.stopWhenNotLoaded) { + try { + await params.service.stop({ env: process.env, stdout, disable: params.opts?.disable }); + } catch (err) { + fail(`${params.serviceNoun} stop failed: ${String(err)}`); + return; + } + emit({ + ok: true, + result: "stopped", + service: buildDaemonServiceSnapshot(params.service, false), + }); + return; + } try { const handled = await params.onNotLoaded?.({ json, stdout, fail }); if (handled) { @@ -413,7 +429,7 @@ export async function runServiceStop(params: { return; } try { - await params.service.stop({ env: process.env, stdout }); + await params.service.stop({ env: process.env, stdout, disable: params.opts?.disable }); } catch (err) { fail(`${params.serviceNoun} stop failed: ${String(err)}`); return; diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 0603c738830..2222dca5a1f 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -123,7 +123,7 @@ describe("runDaemonRestart health checks", () => { safe?: boolean; force?: boolean; }) => Promise; - let runDaemonStop: (opts?: { json?: boolean }) => Promise; + let runDaemonStop: (opts?: { json?: boolean; disable?: boolean }) => Promise; let envSnapshot: ReturnType; function mockUnmanagedRestart({ @@ -447,6 +447,19 @@ describe("runDaemonRestart health checks", () => { expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4300, "SIGTERM"); }); + it("routes macOS disable stops through the service manager when not loaded", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + + await runDaemonStop({ json: true, disable: true }); + + expect(runServiceStop).toHaveBeenCalledWith( + expect.objectContaining({ + opts: { json: true, disable: true }, + stopWhenNotLoaded: true, + }), + ); + }); + it("skips gateway port resolution on stop when the service manager handles the stop", async () => { await runDaemonStop({ json: true }); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 572530b82f1..d0f94c620d6 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -249,6 +249,7 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { serviceNoun: "Gateway", service, opts, + stopWhenNotLoaded: process.platform === "darwin" && Boolean(opts.disable), onNotLoaded: async () => { gatewayPortPromise ??= resolveGatewayLifecyclePort(service).catch(() => resolveGatewayPortFallback(), diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index 335865cad6f..29ea9177d64 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -114,6 +114,11 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri .command("stop") .description("Stop the Gateway service (launchd/systemd/schtasks)") .option("--json", "Output JSON", false) + .option( + "--disable", + "Persistently suppress KeepAlive/RunAtLoad so the gateway does not respawn until next start (launchd only)", + false, + ) .action(async (cmdOpts) => { const { runDaemonStop } = await loadDaemonLifecycleModule(); await runDaemonStop(cmdOpts); diff --git a/src/cli/daemon-cli/types.ts b/src/cli/daemon-cli/types.ts index 5d50d24fa34..0876d49c93f 100644 --- a/src/cli/daemon-cli/types.ts +++ b/src/cli/daemon-cli/types.ts @@ -29,4 +29,5 @@ export type DaemonLifecycleOptions = { force?: boolean; safe?: boolean; wait?: string; + disable?: boolean; }; diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 78fcf92510b..3ebccce75b2 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -403,9 +403,10 @@ describe("launchd bootstrap repair", () => { expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false); }); - it("treats bootstrap exit 130 as success and nudges the already-loaded service", async () => { + it("treats bootstrap exit 130 as success and nudges the already-loaded service when stopped", async () => { state.bootstrapError = "Service already loaded"; state.bootstrapCode = 130; + state.serviceRunning = false; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); @@ -417,9 +418,21 @@ describe("launchd bootstrap repair", () => { ]); }); - it("treats 'already exists in domain' bootstrap failures as success and nudges the service", async () => { + it("skips kickstart when already-loaded service is actively running", async () => { + state.bootstrapError = "Service already loaded"; + state.bootstrapCode = 130; + const env = createDefaultLaunchdEnv(); + + const repair = await repairLaunchAgentBootstrap({ env }); + + expect(repair).toEqual({ ok: true, status: "already-loaded" }); + expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false); + }); + + it("treats 'already exists in domain' bootstrap failures as success and nudges the service when stopped", async () => { state.bootstrapError = "Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501"; + state.serviceRunning = false; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); @@ -448,6 +461,7 @@ describe("launchd bootstrap repair", () => { it("returns a typed kickstart failure when already-loaded recovery cannot nudge the service", async () => { state.bootstrapError = "Service already loaded"; state.bootstrapCode = 130; + state.serviceRunning = false; state.kickstartError = "launchctl kickstart failed: permission denied"; state.kickstartFailuresRemaining = 1; const env = createDefaultLaunchdEnv(); @@ -610,7 +624,7 @@ describe("launchd install", () => { expect(state.fileModes.get(plistPath)).toBe(0o600); }); - it("stops LaunchAgent by disabling relaunch before stopping the process", async () => { + it("stops LaunchAgent via bootout by default, preserving KeepAlive for future crashes", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); let output = ""; @@ -620,6 +634,24 @@ describe("launchd install", () => { await stopLaunchAgent({ env, stdout }); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const serviceId = `${domain}/ai.openclaw.gateway`; + expect(state.launchctlCalls).toContainEqual(["bootout", serviceId]); + expect(state.launchctlCalls.some((call) => call[0] === "disable")).toBe(false); + expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(false); + expect(output).toContain("Stopped LaunchAgent"); + }); + + it("stops LaunchAgent with disable+stop when --disable is passed", async () => { + const env = createDefaultLaunchdEnv(); + const stdout = new PassThrough(); + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + await stopLaunchAgent({ env, stdout, disable: true }); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(state.launchctlCalls).toContainEqual(["disable", serviceId]); @@ -628,7 +660,7 @@ describe("launchd install", () => { expect(output).toContain("Stopped LaunchAgent"); }); - it("treats already-unloaded services as successfully stopped without bootout fallback", async () => { + it("treats already-unloaded services as successfully stopped without bootout fallback (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); let output = ""; @@ -640,7 +672,7 @@ describe("launchd install", () => { output += chunk.toString(); }); - await stopLaunchAgent({ env, stdout }); + await stopLaunchAgent({ env, stdout, disable: true }); expect(state.launchctlCalls).toContainEqual([ "disable", @@ -651,7 +683,24 @@ describe("launchd install", () => { expect(output).not.toContain("degraded"); }); - it("falls back to bootout when disable fails so stop remains authoritative", async () => { + it("treats already-unloaded services as successfully stopped in default bootout path", async () => { + const env = createDefaultLaunchdEnv(); + const stdout = new PassThrough(); + let output = ""; + state.serviceLoaded = false; + state.serviceRunning = false; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + await stopLaunchAgent({ env, stdout }); + + expect(state.launchctlCalls.some((call) => call[0] === "disable")).toBe(false); + expect(output).toContain("Stopped LaunchAgent"); + expect(output).not.toContain("degraded"); + }); + + it("falls back to bootout when disable fails so stop remains authoritative (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); let output = ""; @@ -660,7 +709,7 @@ describe("launchd install", () => { output += chunk.toString(); }); - await stopLaunchAgent({ env, stdout }); + await stopLaunchAgent({ env, stdout, disable: true }); expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(false); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); @@ -668,7 +717,7 @@ describe("launchd install", () => { expect(output).toContain("used bootout fallback"); }); - it("falls back to bootout when stop does not fully stop the service", async () => { + it("falls back to bootout when stop does not fully stop the service (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); let output = ""; @@ -677,7 +726,7 @@ describe("launchd install", () => { output += chunk.toString(); }); - await runStopLaunchAgentWithFakeTimers({ env, stdout }); + await runStopLaunchAgentWithFakeTimers({ env, stdout, disable: true }); expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(true); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); @@ -685,7 +734,7 @@ describe("launchd install", () => { expect(output).toContain("did not fully stop the service"); }); - it("treats launchctl print state=running as running even when pid is missing", async () => { + it("treats launchctl print state=running as running even when pid is missing (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); let output = ""; @@ -695,14 +744,14 @@ describe("launchd install", () => { output += chunk.toString(); }); - await runStopLaunchAgentWithFakeTimers({ env, stdout }); + await runStopLaunchAgentWithFakeTimers({ env, stdout, disable: true }); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("did not fully stop the service"); }); - it("falls back to bootout when launchctl stop itself errors", async () => { + it("falls back to bootout when launchctl stop itself errors (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); let output = ""; @@ -711,14 +760,14 @@ describe("launchd install", () => { output += chunk.toString(); }); - await stopLaunchAgent({ env, stdout }); + await stopLaunchAgent({ env, stdout, disable: true }); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("launchctl stop failed; used bootout fallback"); }); - it("falls back to bootout when launchctl print cannot confirm the stop state", async () => { + it("falls back to bootout when launchctl print cannot confirm the stop state (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); let output = ""; @@ -728,27 +777,39 @@ describe("launchd install", () => { output += chunk.toString(); }); - await runStopLaunchAgentWithFakeTimers({ env, stdout }); + await runStopLaunchAgentWithFakeTimers({ env, stdout, disable: true }); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("could not confirm stop"); }); - it("throws when launchctl print cannot confirm stop and bootout also fails", async () => { + it("throws when launchctl print cannot confirm stop and bootout also fails (--disable)", async () => { const env = createDefaultLaunchdEnv(); state.printError = "launchctl print permission denied"; state.printFailuresRemaining = 10; state.bootoutError = "launchctl bootout permission denied"; await expect( - runStopLaunchAgentWithFakeTimers({ env, stdout: new PassThrough() }), + runStopLaunchAgentWithFakeTimers({ env, stdout: new PassThrough(), disable: true }), ).rejects.toThrow( "launchctl print could not confirm stop; used bootout fallback and left service unloaded: launchctl print permission denied; launchctl bootout failed: launchctl bootout permission denied", ); }); - it("sanitizes launchctl details before writing warnings", async () => { + it("throws when default bootout fails", async () => { + const env = createDefaultLaunchdEnv(); + state.bootoutError = "launchctl bootout permission denied"; + state.bootoutCode = 1; + + await expect(stopLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( + "launchctl bootout failed: launchctl bootout permission denied", + ); + expect(state.launchctlCalls.some((call) => call[0] === "disable")).toBe(false); + expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(false); + }); + + it("sanitizes launchctl details before writing warnings (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); let output = ""; @@ -757,7 +818,7 @@ describe("launchd install", () => { output += chunk.toString(); }); - await stopLaunchAgent({ env, stdout }); + await stopLaunchAgent({ env, stdout, disable: true }); expect(output).not.toContain("\u001b[31m"); expect(output).not.toContain("\nred\n"); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index a79f748a4ff..54c055f74fc 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -464,6 +464,13 @@ export async function repairLaunchAgentBootstrap(args: { return { ok: true, status: repairStatus }; } + // Service is already bootstrapped. Only kickstart if it is not actively running — + // kickstarting a healthy running service causes unnecessary session disconnects. + const runtime = await readLaunchAgentRuntime(env); + if (runtime.status === "running") { + return { ok: true, status: repairStatus }; + } + const kick = await execLaunchctl(["kickstart", serviceTarget]); if (kick.code !== 0) { return { @@ -593,21 +600,36 @@ async function waitForLaunchAgentStopped(serviceTarget: string): Promise { +export async function stopLaunchAgent({ + stdout, + env, + disable: persistDisable, +}: GatewayServiceControlArgs): Promise { const serviceEnv = env ?? (process.env as GatewayServiceEnv); const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env: serviceEnv }); const serviceTarget = `${domain}/${label}`; - // Keep the LaunchAgent installed, but persistently suppress KeepAlive/RunAtLoad - // before stopping the current process. Without `disable`, launchd can relaunch - // the process as soon as `stop` exits. - const disable = await execLaunchctl(["disable", serviceTarget]); - if (disable.code !== 0) { + if (!persistDisable) { + // Default: bootout only. Removes the job from the current launchd domain without + // persisting a disable, so KeepAlive auto-recovery survives future crashes and + // `openclaw gateway start` re-enables cleanly without a manual `launchctl enable`. + const bootout = await execLaunchctl(["bootout", serviceTarget]); + if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); + } + stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`); + return; + } + + // --disable: persistently suppress KeepAlive/RunAtLoad before stopping. + // Without this, launchd can relaunch the process as soon as `stop` exits. + const disableResult = await execLaunchctl(["disable", serviceTarget]); + if (disableResult.code !== 0) { await bootoutLaunchAgentOrThrow({ serviceTarget, stdout, - warning: `launchctl disable failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(disable)}`, + warning: `launchctl disable failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(disableResult)}`, }); return; } diff --git a/src/daemon/service-types.ts b/src/daemon/service-types.ts index 08a66a0ae43..41a22a1a690 100644 --- a/src/daemon/service-types.ts +++ b/src/daemon/service-types.ts @@ -22,6 +22,7 @@ export type GatewayServiceManageArgs = { export type GatewayServiceControlArgs = { stdout: NodeJS.WritableStream; env?: GatewayServiceEnv; + disable?: boolean; }; export type GatewayServiceRestartResult = { outcome: "completed" } | { outcome: "scheduled" };