fix(daemon): reconcile macOS LaunchAgent supervision state (#72616) · openclaw/openclaw@60d4d5e
vincentkoc
·
2026-04-27
·
via Recent Commits to openclaw:main
| Original file line number | Diff line number | Diff line change |
|---|
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
12 | 12 | |
13 | 13 | ### Fixes |
14 | 14 | |
| 15 | +- macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar. |
15 | 16 | - Plugins/install: stage bundled plugin runtime dependencies before Gateway startup and drain update restarts while preserving per-plugin isolation when pre-stage scan or install fails. Thanks @codex. |
16 | 17 | - CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc. |
17 | 18 | - CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -370,17 +370,22 @@ describe("runDaemonRestart health checks", () => {
|
370 | 370 | expect(service.restart).not.toHaveBeenCalled(); |
371 | 371 | }); |
372 | 372 | |
373 | | -it("prefers unmanaged restart over launchd repair when a gateway listener is present", async () => { |
| 373 | +it("prefers launchd repair over unmanaged restart when an installed LaunchAgent is unloaded", async () => { |
374 | 374 | vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); |
| 375 | +recoverInstalledLaunchAgent.mockResolvedValue({ |
| 376 | +result: "restarted", |
| 377 | +loaded: true, |
| 378 | +message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.", |
| 379 | +}); |
375 | 380 | findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]); |
376 | 381 | mockUnmanagedRestart({ runPostRestartCheck: true }); |
377 | 382 | |
378 | 383 | await runDaemonRestart({ json: true }); |
379 | 384 | |
380 | | -expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1"); |
381 | | -expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled(); |
382 | | -expect(waitForGatewayHealthyListener).toHaveBeenCalledTimes(1); |
383 | | -expect(waitForGatewayHealthyRestart).not.toHaveBeenCalled(); |
| 385 | +expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "restarted" }); |
| 386 | +expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled(); |
| 387 | +expect(waitForGatewayHealthyListener).not.toHaveBeenCalled(); |
| 388 | +expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1); |
384 | 389 | }); |
385 | 390 | |
386 | 391 | it("re-bootstraps an installed LaunchAgent on restart when no unmanaged listener exists", async () => { |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -201,12 +201,18 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
|
201 | 201 | opts, |
202 | 202 | checkTokenDrift: true, |
203 | 203 | onNotLoaded: async () => { |
| 204 | +if (process.platform === "darwin") { |
| 205 | +const recovered = await recoverInstalledLaunchAgent({ result: "restarted" }); |
| 206 | +if (recovered) { |
| 207 | +return recovered; |
| 208 | +} |
| 209 | +} |
204 | 210 | const handled = await restartGatewayWithoutServiceManager(restartPort); |
205 | 211 | if (handled) { |
206 | 212 | restartedWithoutServiceManager = true; |
207 | 213 | return handled; |
208 | 214 | } |
209 | | -return await recoverInstalledLaunchAgent({ result: "restarted" }); |
| 215 | +return null; |
210 | 216 | }, |
211 | 217 | postRestartCheck: async ({ warnings, fail, stdout }) => { |
212 | 218 | if (restartedWithoutServiceManager) { |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -144,7 +144,7 @@ export function normalizeListenerAddress(raw: string): string {
|
144 | 144 | } |
145 | 145 | |
146 | 146 | export function renderRuntimeHints( |
147 | | -runtime: { missingUnit?: boolean; status?: string } | undefined, |
| 147 | +runtime: { missingUnit?: boolean; missingSupervision?: boolean; status?: string } | undefined, |
148 | 148 | env: NodeJS.ProcessEnv = process.env, |
149 | 149 | logFile?: string | null, |
150 | 150 | ): string[] { |
@@ -160,6 +160,15 @@ export function renderRuntimeHints(
|
160 | 160 | } |
161 | 161 | return hints; |
162 | 162 | } |
| 163 | +if (runtime.missingSupervision) { |
| 164 | +hints.push( |
| 165 | +`LaunchAgent installed but not loaded. Run: ${formatCliCommand("openclaw gateway restart", env)}`, |
| 166 | +); |
| 167 | +if (fileLog) { |
| 168 | +hints.push(`File logs: ${fileLog}`); |
| 169 | +} |
| 170 | +return hints; |
| 171 | +} |
163 | 172 | if (runtime.status === "stopped") { |
164 | 173 | if (fileLog) { |
165 | 174 | hints.push(`File logs: ${fileLog}`); |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -251,6 +251,15 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
251 | 251 | for (const hint of renderRuntimeHints(service.runtime, process.env, status.logFile)) { |
252 | 252 | defaultRuntime.error(errorText(hint)); |
253 | 253 | } |
| 254 | +} else if (service.runtime?.missingSupervision) { |
| 255 | +defaultRuntime.error(errorText("LaunchAgent plist exists but launchd has no loaded job.")); |
| 256 | +for (const hint of renderRuntimeHints( |
| 257 | +service.runtime, |
| 258 | +service.command?.environment ?? process.env, |
| 259 | +status.logFile, |
| 260 | +)) { |
| 261 | +defaultRuntime.error(errorText(hint)); |
| 262 | +} |
254 | 263 | } else if (service.loaded && service.runtime?.status === "stopped") { |
255 | 264 | defaultRuntime.error( |
256 | 265 | errorText("Service is loaded but not running (likely exited immediately)."), |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -72,6 +72,15 @@ export function buildGatewayRuntimeHints(
|
72 | 72 | } |
73 | 73 | return hints; |
74 | 74 | } |
| 75 | +if (runtime.missingSupervision && platform === "darwin") { |
| 76 | +hints.push( |
| 77 | +`LaunchAgent installed but not loaded. Run: ${formatCliCommand("openclaw gateway restart", env)}`, |
| 78 | +); |
| 79 | +if (fileLog) { |
| 80 | +hints.push(`File logs: ${fileLog}`); |
| 81 | +} |
| 82 | +return hints; |
| 83 | +} |
75 | 84 | if (runtime.status === "stopped") { |
76 | 85 | hints.push("Service is loaded but not running (likely exited immediately)."); |
77 | 86 | if (fileLog) { |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -294,7 +294,6 @@ describe("maybeRepairGatewayDaemon", () => {
|
294 | 294 | it("skips LaunchAgent bootstrap repair when service repair policy is external", async () => { |
295 | 295 | setPlatform("darwin"); |
296 | 296 | service.isLoaded.mockResolvedValue(false); |
297 | | -vi.mocked(launchd.isLaunchAgentListed).mockResolvedValue(true); |
298 | 297 | vi.mocked(launchd.isLaunchAgentLoaded).mockResolvedValue(false); |
299 | 298 | vi.mocked(launchd.launchAgentPlistExists).mockResolvedValue(true); |
300 | 299 | |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -7,7 +7,6 @@ import {
|
7 | 7 | } from "../daemon/constants.js"; |
8 | 8 | import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; |
9 | 9 | import { |
10 | | -isLaunchAgentListed, |
11 | 10 | isLaunchAgentLoaded, |
12 | 11 | launchAgentPlistExists, |
13 | 12 | repairLaunchAgentBootstrap, |
@@ -49,8 +48,8 @@ async function maybeRepairLaunchAgentBootstrap(params: {
|
49 | 48 | return false; |
50 | 49 | } |
51 | 50 | |
52 | | -const listed = await isLaunchAgentListed({ env: params.env }); |
53 | | -if (!listed) { |
| 51 | +const plistExists = await launchAgentPlistExists(params.env); |
| 52 | +if (!plistExists) { |
54 | 53 | return false; |
55 | 54 | } |
56 | 55 | |
@@ -59,12 +58,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
|
59 | 58 | return false; |
60 | 59 | } |
61 | 60 | |
62 | | -const plistExists = await launchAgentPlistExists(params.env); |
63 | | -if (!plistExists) { |
64 | | -return false; |
65 | | -} |
66 | | - |
67 | | -note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`); |
| 61 | +note("LaunchAgent is installed but not loaded in launchd.", `${params.title} LaunchAgent`); |
68 | 62 | if (params.serviceRepairExternal) { |
69 | 63 | note(EXTERNAL_SERVICE_REPAIR_NOTE, `${params.title} LaunchAgent`); |
70 | 64 | return false; |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -187,6 +187,19 @@ describeLaunchdIntegration("launchd integration", () => {
|
187 | 187 | await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); |
188 | 188 | }, 60_000); |
189 | 189 | |
| 190 | +it("keeps LaunchAgent supervision after a raw SIGTERM", async () => { |
| 191 | +const launchEnv = launchEnvOrThrow(env); |
| 192 | +try { |
| 193 | +await initializeLaunchdRuntime(launchEnv, stdout); |
| 194 | +} catch { |
| 195 | +return; |
| 196 | +} |
| 197 | + |
| 198 | +const before = await waitForRunningRuntime({ env: launchEnv }); |
| 199 | +process.kill(before.pid, "SIGTERM"); |
| 200 | +await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); |
| 201 | +}, 60_000); |
| 202 | + |
190 | 203 | it("stops persistently without reinstall and starts later", async () => { |
191 | 204 | const launchEnv = launchEnvOrThrow(env); |
192 | 205 | try { |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -8,6 +8,7 @@ import {
|
8 | 8 | installLaunchAgent, |
9 | 9 | isLaunchAgentListed, |
10 | 10 | parseLaunchctlPrint, |
| 11 | +readLaunchAgentRuntime, |
11 | 12 | repairLaunchAgentBootstrap, |
12 | 13 | restartLaunchAgent, |
13 | 14 | resolveLaunchAgentPlistPath, |
@@ -349,6 +350,30 @@ describe("launchd runtime parsing", () => {
|
349 | 350 | }); |
350 | 351 | }); |
351 | 352 | |
| 353 | +describe("launchd runtime state", () => { |
| 354 | +it("marks installed plist split-brain when launchd no longer has the job", async () => { |
| 355 | +const env = createDefaultLaunchdEnv(); |
| 356 | +state.files.set(resolveLaunchAgentPlistPath(env), "<plist/>"); |
| 357 | +state.serviceLoaded = false; |
| 358 | + |
| 359 | +await expect(readLaunchAgentRuntime(env)).resolves.toMatchObject({ |
| 360 | +status: "unknown", |
| 361 | +missingSupervision: true, |
| 362 | +detail: "Could not find service", |
| 363 | +}); |
| 364 | +}); |
| 365 | + |
| 366 | +it("marks a missing unit when launchd has no job and no plist exists", async () => { |
| 367 | +const env = createDefaultLaunchdEnv(); |
| 368 | +state.serviceLoaded = false; |
| 369 | + |
| 370 | +await expect(readLaunchAgentRuntime(env)).resolves.toMatchObject({ |
| 371 | +status: "unknown", |
| 372 | +missingUnit: true, |
| 373 | +}); |
| 374 | +}); |
| 375 | +}); |
| 376 | + |
352 | 377 | describe("launchctl list detection", () => { |
353 | 378 | it("detects the resolved label in launchctl list", async () => { |
354 | 379 | state.listOutput = "123 0 ai.openclaw.gateway\n"; |
|
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。