Skip to content

Commit 1f88cb2

Browse files
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 <[email protected]>
1 parent fe79d85 commit 1f88cb2

12 files changed

Lines changed: 182 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ Docs: https://docs.openclaw.ai
171171

172172
### Fixes
173173

174+
- 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.
175+
- 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.
176+
- 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.
174177
- 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.
175178
- 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.
176179
- 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.

docs/cli/gateway.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,11 +483,13 @@ openclaw gateway restart
483483
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
484484
- `gateway install`: `--port`, `--runtime <node|bun>`, `--token`, `--wrapper <path>`, `--force`, `--json`
485485
- `gateway restart`: `--safe`, `--force`, `--wait <duration>`, `--json`
486-
- `gateway uninstall|start|stop`: `--json`
486+
- `gateway uninstall|start`: `--json`
487+
- `gateway stop`: `--disable`, `--json`
487488

488489
</Accordion>
489490
<Accordion title="Lifecycle behavior">
490-
- 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.
491+
- Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute.
492+
- 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.
491493
- `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`.
492494
- `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.
493495
- `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.

docs/gateway/index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,9 @@ openclaw gateway restart
217217
openclaw gateway stop
218218
```
219219

220-
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.
220+
Use `openclaw gateway restart` for restarts. Do not chain `openclaw gateway stop` and `openclaw gateway start` as a restart substitute.
221+
222+
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`.
221223

222224
LaunchAgent labels are `ai.openclaw.gateway` (default) or `ai.openclaw.<profile>` (named profile). `openclaw doctor` audits and repairs service config drift.
223225

src/cli/daemon-cli/lifecycle-core.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,30 @@ describe("runServiceRestart token drift", () => {
216216
expect(service.stop).not.toHaveBeenCalled();
217217
});
218218

219+
it("runs a requested managed stop even when the service is not loaded", async () => {
220+
const onNotLoaded = vi.fn(async () => ({
221+
result: "stopped" as const,
222+
message: "Gateway stop signal sent to unmanaged process on port 18789: 4200.",
223+
}));
224+
service.isLoaded.mockResolvedValue(false);
225+
226+
await runServiceStop({
227+
serviceNoun: "Gateway",
228+
service,
229+
opts: { json: true, disable: true },
230+
stopWhenNotLoaded: true,
231+
onNotLoaded,
232+
});
233+
234+
const payload = readJsonLog<{ result?: string; service?: { loaded?: boolean } }>();
235+
expect(payload.result).toBe("stopped");
236+
expect(payload.service?.loaded).toBe(false);
237+
expect(service.stop).toHaveBeenCalledWith(
238+
expect.objectContaining({ env: process.env, disable: true }),
239+
);
240+
expect(onNotLoaded).not.toHaveBeenCalled();
241+
});
242+
219243
it("emits started when a not-loaded start path repairs the service", async () => {
220244
service.isLoaded.mockResolvedValue(false);
221245

src/cli/daemon-cli/lifecycle-core.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type DaemonLifecycleOptions = {
3232
force?: boolean;
3333
wait?: string;
3434
restartIntent?: GatewayRestartIntent;
35+
disable?: boolean;
3536
};
3637

3738
type RestartPostCheckContext = {
@@ -362,6 +363,7 @@ export async function runServiceStop(params: {
362363
service: GatewayService;
363364
opts?: DaemonLifecycleOptions;
364365
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
366+
stopWhenNotLoaded?: boolean;
365367
}) {
366368
const json = Boolean(params.opts?.json);
367369
const { stdout, emit, fail } = createDaemonActionContext({ action: "stop", json });
@@ -382,6 +384,20 @@ export async function runServiceStop(params: {
382384
}
383385
}
384386
if (!loaded) {
387+
if (params.stopWhenNotLoaded) {
388+
try {
389+
await params.service.stop({ env: process.env, stdout, disable: params.opts?.disable });
390+
} catch (err) {
391+
fail(`${params.serviceNoun} stop failed: ${String(err)}`);
392+
return;
393+
}
394+
emit({
395+
ok: true,
396+
result: "stopped",
397+
service: buildDaemonServiceSnapshot(params.service, false),
398+
});
399+
return;
400+
}
385401
try {
386402
const handled = await params.onNotLoaded?.({ json, stdout, fail });
387403
if (handled) {
@@ -413,7 +429,7 @@ export async function runServiceStop(params: {
413429
return;
414430
}
415431
try {
416-
await params.service.stop({ env: process.env, stdout });
432+
await params.service.stop({ env: process.env, stdout, disable: params.opts?.disable });
417433
} catch (err) {
418434
fail(`${params.serviceNoun} stop failed: ${String(err)}`);
419435
return;

src/cli/daemon-cli/lifecycle.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ describe("runDaemonRestart health checks", () => {
123123
safe?: boolean;
124124
force?: boolean;
125125
}) => Promise<boolean>;
126-
let runDaemonStop: (opts?: { json?: boolean }) => Promise<void>;
126+
let runDaemonStop: (opts?: { json?: boolean; disable?: boolean }) => Promise<void>;
127127
let envSnapshot: ReturnType<typeof captureEnv>;
128128

129129
function mockUnmanagedRestart({
@@ -447,6 +447,19 @@ describe("runDaemonRestart health checks", () => {
447447
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4300, "SIGTERM");
448448
});
449449

450+
it("routes macOS disable stops through the service manager when not loaded", async () => {
451+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
452+
453+
await runDaemonStop({ json: true, disable: true });
454+
455+
expect(runServiceStop).toHaveBeenCalledWith(
456+
expect.objectContaining({
457+
opts: { json: true, disable: true },
458+
stopWhenNotLoaded: true,
459+
}),
460+
);
461+
});
462+
450463
it("skips gateway port resolution on stop when the service manager handles the stop", async () => {
451464
await runDaemonStop({ json: true });
452465

src/cli/daemon-cli/lifecycle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
249249
serviceNoun: "Gateway",
250250
service,
251251
opts,
252+
stopWhenNotLoaded: process.platform === "darwin" && Boolean(opts.disable),
252253
onNotLoaded: async () => {
253254
gatewayPortPromise ??= resolveGatewayLifecyclePort(service).catch(() =>
254255
resolveGatewayPortFallback(),

src/cli/daemon-cli/register-service-commands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri
114114
.command("stop")
115115
.description("Stop the Gateway service (launchd/systemd/schtasks)")
116116
.option("--json", "Output JSON", false)
117+
.option(
118+
"--disable",
119+
"Persistently suppress KeepAlive/RunAtLoad so the gateway does not respawn until next start (launchd only)",
120+
false,
121+
)
117122
.action(async (cmdOpts) => {
118123
const { runDaemonStop } = await loadDaemonLifecycleModule();
119124
await runDaemonStop(cmdOpts);

src/cli/daemon-cli/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ export type DaemonLifecycleOptions = {
2929
force?: boolean;
3030
safe?: boolean;
3131
wait?: string;
32+
disable?: boolean;
3233
};

0 commit comments

Comments
 (0)