fix(daemon): reconcile macOS LaunchAgent supervision state (#72616)

This commit is contained in:
Vincent Koc
2026-04-26 22:39:15 -07:00
committed by GitHub
parent 8c2f894d3a
commit 60d4d5e1fa
12 changed files with 90 additions and 18 deletions

View File

@@ -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 () => {

View File

@@ -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) {

View File

@@ -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}`);

View File

@@ -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)."),