Summary
openclaw gateway stop (on macOS) calls launchctl disable unconditionally before stopping the LaunchAgent. Once a service is disabled, launchd treats KeepAlive=true as a no-op until the service is re-enabled with launchctl enable. This silently breaks the gateway's auto-recovery story.
Reproduction
dist/launchd-8T4SfMJh.js:470-505:
async function stopLaunchAgent({ stdout, env }) {
const serviceEnv = env ?? process.env;
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env: serviceEnv });
const serviceTarget = `${domain}/${label}`;
const disable = await execLaunchctl(["disable", serviceTarget]); // ← unconditional
if (disable.code !== 0) {
await bootoutLaunchAgentOrThrow({ ... });
return;
}
// ... stop / wait / bootout ...
}
After openclaw gateway stop, the LaunchAgent is in state = disabled and KeepAlive cannot fire. The plist is still on disk; launchctl bootstrap gui/$UID/<plist> returns success but the service stays disabled and does not actually start.
Observed impact
On 2026-05-05 09:11 EDT this gateway took an embedded 5-min LLM-call timeout, the run controller escalated to a process-level shutdown, and launchd KeepAlive=true did not respawn the gateway. The user (Brian) had to manually run:
launchctl enable gui/501/ai.openclaw.gateway
launchctl bootstrap gui/501 ~/Library/LaunchAgents/ai.openclaw.gateway.plist
…to recover. Total downtime ~55 minutes during a workday. This is the second multi-hour outage in a week traced to the same trap.
The trap is also opaque to operators: launchctl print gui/$UID/<label> shows state = disabled only if you read carefully; nothing in openclaw status / openclaw doctor (as of this version) calls out the disable state with a recovery hint.
Proposed fix
Make disable opt-in via a --disable (or --permanent) flag.
async function stopLaunchAgent({ stdout, env, permanent = false }) {
const serviceTarget = `${domain}/${label}`;
if (permanent) {
const disable = await execLaunchctl(["disable", serviceTarget]);
if (disable.code !== 0) { /* existing fallback */ }
}
// Default path: bootout only — service stays enabled, KeepAlive can respawn.
await bootoutLaunchAgentOrThrow({ serviceTarget, stdout, warning: "stopped" });
}
CLI:
openclaw gateway stop # bootout only, service stays enabled, KeepAlive respawns
openclaw gateway stop --disable # current behavior: disable + stop, no respawn
Also worth surfacing in openclaw doctor: if a cached LaunchAgent label resolves to state = disabled, print a one-liner with the launchctl enable recovery command. (Doctor currently flags missing-plist with a bootout hint at dist/doctor-gateway-daemon-flow-BJRWGNB5.js:51 — same pattern, different state.)
Why this matters
KeepAlive=true is the headline reliability feature for the gateway daemon. Any path that silently nullifies it is a footgun. With this fix, the documented "if the gateway crashes, launchd will restart it" property actually holds for unexpected/abnormal stops. With the current code, KeepAlive only protects against process-crashes that don't go through gateway stop — which excludes the most common recovery flow.
Environment
- OpenClaw installed from npm at
/opt/homebrew/lib/node_modules/openclaw/ (macOS, M-series, node v22)
- LaunchAgent label
ai.openclaw.gateway, domain gui/501
- Diagnostic timestamp: 2026-05-05
Summary
openclaw gateway stop(on macOS) callslaunchctl disableunconditionally before stopping the LaunchAgent. Once a service isdisabled, launchd treatsKeepAlive=trueas a no-op until the service is re-enabled withlaunchctl enable. This silently breaks the gateway's auto-recovery story.Reproduction
dist/launchd-8T4SfMJh.js:470-505:After
openclaw gateway stop, the LaunchAgent is instate = disabledand KeepAlive cannot fire. The plist is still on disk;launchctl bootstrap gui/$UID/<plist>returns success but the service stays disabled and does not actually start.Observed impact
On 2026-05-05 09:11 EDT this gateway took an embedded 5-min LLM-call timeout, the run controller escalated to a process-level shutdown, and launchd KeepAlive=true did not respawn the gateway. The user (Brian) had to manually run:
…to recover. Total downtime ~55 minutes during a workday. This is the second multi-hour outage in a week traced to the same trap.
The trap is also opaque to operators:
launchctl print gui/$UID/<label>showsstate = disabledonly if you read carefully; nothing inopenclaw status/openclaw doctor(as of this version) calls out the disable state with a recovery hint.Proposed fix
Make
disableopt-in via a--disable(or--permanent) flag.CLI:
Also worth surfacing in
openclaw doctor: if a cached LaunchAgent label resolves tostate = disabled, print a one-liner with thelaunchctl enablerecovery command. (Doctor currently flags missing-plist with a bootout hint atdist/doctor-gateway-daemon-flow-BJRWGNB5.js:51— same pattern, different state.)Why this matters
KeepAlive=trueis the headline reliability feature for the gateway daemon. Any path that silently nullifies it is a footgun. With this fix, the documented "if the gateway crashes, launchd will restart it" property actually holds for unexpected/abnormal stops. With the current code,KeepAliveonly protects against process-crashes that don't go throughgateway stop— which excludes the most common recovery flow.Environment
/opt/homebrew/lib/node_modules/openclaw/(macOS, M-series, node v22)ai.openclaw.gateway, domaingui/501