mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix(daemon): reconcile macOS LaunchAgent supervision state (#72616)
This commit is contained in:
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -370,17 +370,22 @@ describe("runDaemonRestart health checks", () => {
|
||||
expect(service.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers unmanaged restart over launchd repair when a gateway listener is present", async () => {
|
||||
it("prefers launchd repair over unmanaged restart when an installed LaunchAgent is unloaded", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
recoverInstalledLaunchAgent.mockResolvedValue({
|
||||
result: "restarted",
|
||||
loaded: true,
|
||||
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
|
||||
});
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
mockUnmanagedRestart({ runPostRestartCheck: true });
|
||||
|
||||
await runDaemonRestart({ json: true });
|
||||
|
||||
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1");
|
||||
expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled();
|
||||
expect(waitForGatewayHealthyListener).toHaveBeenCalledTimes(1);
|
||||
expect(waitForGatewayHealthyRestart).not.toHaveBeenCalled();
|
||||
expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "restarted" });
|
||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||
expect(waitForGatewayHealthyListener).not.toHaveBeenCalled();
|
||||
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-bootstraps an installed LaunchAgent on restart when no unmanaged listener exists", async () => {
|
||||
|
||||
@@ -201,12 +201,18 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
|
||||
opts,
|
||||
checkTokenDrift: true,
|
||||
onNotLoaded: async () => {
|
||||
if (process.platform === "darwin") {
|
||||
const recovered = await recoverInstalledLaunchAgent({ result: "restarted" });
|
||||
if (recovered) {
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
const handled = await restartGatewayWithoutServiceManager(restartPort);
|
||||
if (handled) {
|
||||
restartedWithoutServiceManager = true;
|
||||
return handled;
|
||||
}
|
||||
return await recoverInstalledLaunchAgent({ result: "restarted" });
|
||||
return null;
|
||||
},
|
||||
postRestartCheck: async ({ warnings, fail, stdout }) => {
|
||||
if (restartedWithoutServiceManager) {
|
||||
|
||||
@@ -144,7 +144,7 @@ export function normalizeListenerAddress(raw: string): string {
|
||||
}
|
||||
|
||||
export function renderRuntimeHints(
|
||||
runtime: { missingUnit?: boolean; status?: string } | undefined,
|
||||
runtime: { missingUnit?: boolean; missingSupervision?: boolean; status?: string } | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
logFile?: string | null,
|
||||
): string[] {
|
||||
@@ -160,6 +160,15 @@ export function renderRuntimeHints(
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
if (runtime.missingSupervision) {
|
||||
hints.push(
|
||||
`LaunchAgent installed but not loaded. Run: ${formatCliCommand("openclaw gateway restart", env)}`,
|
||||
);
|
||||
if (fileLog) {
|
||||
hints.push(`File logs: ${fileLog}`);
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
if (runtime.status === "stopped") {
|
||||
if (fileLog) {
|
||||
hints.push(`File logs: ${fileLog}`);
|
||||
|
||||
@@ -251,6 +251,15 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
for (const hint of renderRuntimeHints(service.runtime, process.env, status.logFile)) {
|
||||
defaultRuntime.error(errorText(hint));
|
||||
}
|
||||
} else if (service.runtime?.missingSupervision) {
|
||||
defaultRuntime.error(errorText("LaunchAgent plist exists but launchd has no loaded job."));
|
||||
for (const hint of renderRuntimeHints(
|
||||
service.runtime,
|
||||
service.command?.environment ?? process.env,
|
||||
status.logFile,
|
||||
)) {
|
||||
defaultRuntime.error(errorText(hint));
|
||||
}
|
||||
} else if (service.loaded && service.runtime?.status === "stopped") {
|
||||
defaultRuntime.error(
|
||||
errorText("Service is loaded but not running (likely exited immediately)."),
|
||||
|
||||
@@ -72,6 +72,15 @@ export function buildGatewayRuntimeHints(
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
if (runtime.missingSupervision && platform === "darwin") {
|
||||
hints.push(
|
||||
`LaunchAgent installed but not loaded. Run: ${formatCliCommand("openclaw gateway restart", env)}`,
|
||||
);
|
||||
if (fileLog) {
|
||||
hints.push(`File logs: ${fileLog}`);
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
if (runtime.status === "stopped") {
|
||||
hints.push("Service is loaded but not running (likely exited immediately).");
|
||||
if (fileLog) {
|
||||
|
||||
@@ -294,7 +294,6 @@ describe("maybeRepairGatewayDaemon", () => {
|
||||
it("skips LaunchAgent bootstrap repair when service repair policy is external", async () => {
|
||||
setPlatform("darwin");
|
||||
service.isLoaded.mockResolvedValue(false);
|
||||
vi.mocked(launchd.isLaunchAgentListed).mockResolvedValue(true);
|
||||
vi.mocked(launchd.isLaunchAgentLoaded).mockResolvedValue(false);
|
||||
vi.mocked(launchd.launchAgentPlistExists).mockResolvedValue(true);
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "../daemon/constants.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import {
|
||||
isLaunchAgentListed,
|
||||
isLaunchAgentLoaded,
|
||||
launchAgentPlistExists,
|
||||
repairLaunchAgentBootstrap,
|
||||
@@ -49,8 +48,8 @@ async function maybeRepairLaunchAgentBootstrap(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const listed = await isLaunchAgentListed({ env: params.env });
|
||||
if (!listed) {
|
||||
const plistExists = await launchAgentPlistExists(params.env);
|
||||
if (!plistExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -59,12 +58,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const plistExists = await launchAgentPlistExists(params.env);
|
||||
if (!plistExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`);
|
||||
note("LaunchAgent is installed but not loaded in launchd.", `${params.title} LaunchAgent`);
|
||||
if (params.serviceRepairExternal) {
|
||||
note(EXTERNAL_SERVICE_REPAIR_NOTE, `${params.title} LaunchAgent`);
|
||||
return false;
|
||||
|
||||
@@ -187,6 +187,19 @@ describeLaunchdIntegration("launchd integration", () => {
|
||||
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
|
||||
}, 60_000);
|
||||
|
||||
it("keeps LaunchAgent supervision after a raw SIGTERM", async () => {
|
||||
const launchEnv = launchEnvOrThrow(env);
|
||||
try {
|
||||
await initializeLaunchdRuntime(launchEnv, stdout);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = await waitForRunningRuntime({ env: launchEnv });
|
||||
process.kill(before.pid, "SIGTERM");
|
||||
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
|
||||
}, 60_000);
|
||||
|
||||
it("stops persistently without reinstall and starts later", async () => {
|
||||
const launchEnv = launchEnvOrThrow(env);
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
installLaunchAgent,
|
||||
isLaunchAgentListed,
|
||||
parseLaunchctlPrint,
|
||||
readLaunchAgentRuntime,
|
||||
repairLaunchAgentBootstrap,
|
||||
restartLaunchAgent,
|
||||
resolveLaunchAgentPlistPath,
|
||||
@@ -349,6 +350,30 @@ describe("launchd runtime parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchd runtime state", () => {
|
||||
it("marks installed plist split-brain when launchd no longer has the job", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
state.files.set(resolveLaunchAgentPlistPath(env), "<plist/>");
|
||||
state.serviceLoaded = false;
|
||||
|
||||
await expect(readLaunchAgentRuntime(env)).resolves.toMatchObject({
|
||||
status: "unknown",
|
||||
missingSupervision: true,
|
||||
detail: "Could not find service",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a missing unit when launchd has no job and no plist exists", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
state.serviceLoaded = false;
|
||||
|
||||
await expect(readLaunchAgentRuntime(env)).resolves.toMatchObject({
|
||||
status: "unknown",
|
||||
missingUnit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchctl list detection", () => {
|
||||
it("detects the resolved label in launchctl list", async () => {
|
||||
state.listOutput = "123 0 ai.openclaw.gateway\n";
|
||||
|
||||
@@ -305,10 +305,11 @@ export async function readLaunchAgentRuntime(
|
||||
const label = resolveLaunchAgentLabel({ env });
|
||||
const res = await execLaunchctl(["print", `${domain}/${label}`]);
|
||||
if (res.code !== 0) {
|
||||
const plistExists = await launchAgentPlistExists(env);
|
||||
return {
|
||||
status: "unknown",
|
||||
detail: (res.stderr || res.stdout).trim() || undefined,
|
||||
missingUnit: true,
|
||||
...(plistExists ? { missingSupervision: true } : { missingUnit: true }),
|
||||
};
|
||||
}
|
||||
const parsed = parseLaunchctlPrint(res.stdout || res.stderr || "");
|
||||
|
||||
@@ -10,4 +10,5 @@ export type GatewayServiceRuntime = {
|
||||
detail?: string;
|
||||
cachedLabel?: boolean;
|
||||
missingUnit?: boolean;
|
||||
missingSupervision?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user