Skip to content

openclaw gateway stop calls launchctl disable unconditionally, breaks KeepAlive auto-recovery #77934

Description

@bmoran1022

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions