fix: avoid fresh launchd repair kickstart

This commit is contained in:
Peter Steinberger
2026-05-03 20:59:18 +01:00
parent 62fb50d7fc
commit b726214cf3
4 changed files with 91 additions and 16 deletions

View File

@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
- Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay.
- CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable <plugin>` repair command. (#76835)
- Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209.
- Gateway/macOS: stop `doctor` and LaunchAgent recovery from running `launchctl kickstart -k` after a fresh bootstrap, avoiding an immediate SIGTERM of the just-started gateway while still nudging already-loaded launchd jobs. Fixes #76261. Thanks @solosage1.
- Google Meet: route stateful CLI session commands through the gateway-owned runtime so joined realtime sessions survive after the starting CLI process exits. Fixes #76344. Thanks @coltonharris-wq.
- Memory/status: split builtin sqlite-vec store readiness from embedding-provider readiness in `memory status --deep` and `openclaw status`, so local vector-store failures no longer look like provider failures and provider failures no longer hide a healthy local vector store.
- CLI/doctor: trust a ready gateway memory probe when CLI-side active memory backend resolution is unavailable, preventing false "No active memory plugin is registered" warnings for healthy runtime setups. Fixes #76792. Thanks @som-686.

View File

@@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
installLaunchAgent,
readLaunchAgentRuntime,
repairLaunchAgentBootstrap,
restartLaunchAgent,
resolveLaunchAgentPlistPath,
stopLaunchAgent,
@@ -37,6 +38,10 @@ function canRunLaunchdIntegration(): boolean {
const describeLaunchdIntegration = canRunLaunchdIntegration() ? describe : describe.skip;
function resolveGuiDomain(): string {
return `gui/${process.getuid?.() ?? 501}`;
}
async function withTimeout<T>(params: {
run: () => Promise<T>;
timeoutMs: number;
@@ -133,6 +138,29 @@ async function initializeLaunchdRuntime(launchEnv: GatewayServiceEnv, stdout: Pa
});
}
async function writeLaunchAgentProbeScript(params: {
eventsPath: string;
scriptPath: string;
}): Promise<void> {
await fs.writeFile(
params.scriptPath,
[
'const fs = require("node:fs");',
`const eventsPath = ${JSON.stringify(params.eventsPath)};`,
"fs.appendFileSync(eventsPath, `start ${process.pid}\\n`);",
'for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {',
" process.on(signal, () => {",
" fs.appendFileSync(eventsPath, `${signal} ${process.pid}\\n`);",
" process.exit(0);",
" });",
"}",
"setInterval(() => {}, 1000);",
"",
].join("\n"),
"utf8",
);
}
async function expectRuntimePidReplaced(params: {
env: GatewayServiceEnv;
previousPid: number;
@@ -231,4 +259,42 @@ describeLaunchdIntegration("launchd integration", () => {
await restartLaunchAgent({ env: launchEnv, stdout });
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
}, 60_000);
it("repairs a missing bootstrap without kickstarting the fresh LaunchAgent", async () => {
const launchEnv = launchEnvOrThrow(env);
const eventsPath = path.join(homeDir, "repair-probe.events.log");
const scriptPath = path.join(homeDir, "repair-probe.cjs");
await writeLaunchAgentProbeScript({ eventsPath, scriptPath });
await installLaunchAgent({
env: launchEnv,
stdout,
programArguments: [process.execPath, scriptPath],
});
await waitForRunningRuntime({ env: launchEnv });
const bootout = spawnSync(
"launchctl",
["bootout", resolveGuiDomain(), resolveLaunchAgentPlistPath(launchEnv)],
{ encoding: "utf8" },
);
expect(bootout.status).toBe(0);
await waitForNotRunningRuntime({ env: launchEnv });
await fs.access(resolveLaunchAgentPlistPath(launchEnv));
await fs.writeFile(eventsPath, "", "utf8");
const repair = await withTimeout({
run: async () => repairLaunchAgentBootstrap({ env: launchEnv }),
timeoutMs: STARTUP_TIMEOUT_MS,
message: "Timed out repairing launchd integration runtime",
});
expect(repair).toEqual({ ok: true, status: "repaired" });
await waitForRunningRuntime({ env: launchEnv });
await new Promise((resolve) => {
setTimeout(resolve, 1_500);
});
const events = await fs.readFile(eventsPath, "utf8");
const lines = events.trim().split(/\r?\n/).filter(Boolean);
expect(lines.filter((line) => line.startsWith("start "))).toHaveLength(1);
expect(lines.some((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line))).toBe(false);
}, 60_000);
});

View File

@@ -394,40 +394,41 @@ describe("launchctl list detection", () => {
});
describe("launchd bootstrap repair", () => {
it("enables, bootstraps, and kickstarts the resolved label", async () => {
it("enables and bootstraps the resolved label without kickstarting the fresh agent", async () => {
const env = createDefaultLaunchdEnv();
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair).toEqual({ ok: true, status: "repaired" });
const { serviceId, bootstrapIndex } = expectLaunchctlEnableBootstrapOrder(env);
const kickstartIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId,
);
expect(kickstartIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeLessThan(kickstartIndex);
expectLaunchctlEnableBootstrapOrder(env);
expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false);
});
it("treats bootstrap exit 130 as success", async () => {
it("treats bootstrap exit 130 as success and nudges the already-loaded service", async () => {
state.bootstrapError = "Service already loaded";
state.bootstrapCode = 130;
const env = createDefaultLaunchdEnv();
const repair = await repairLaunchAgentBootstrap({ env });
const { serviceId } = expectLaunchctlEnableBootstrapOrder(env);
expect(repair).toEqual({ ok: true, status: "already-loaded" });
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1);
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([
["kickstart", serviceId],
]);
});
it("treats 'already exists in domain' bootstrap failures as success", async () => {
it("treats 'already exists in domain' bootstrap failures as success and nudges the service", async () => {
state.bootstrapError =
"Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501";
const env = createDefaultLaunchdEnv();
const repair = await repairLaunchAgentBootstrap({ env });
const { serviceId } = expectLaunchctlEnableBootstrapOrder(env);
expect(repair).toEqual({ ok: true, status: "already-loaded" });
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1);
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([
["kickstart", serviceId],
]);
});
it("keeps genuine bootstrap failures as failures", async () => {
@@ -444,7 +445,9 @@ describe("launchd bootstrap repair", () => {
expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false);
});
it("returns a typed kickstart failure", async () => {
it("returns a typed kickstart failure when already-loaded recovery cannot nudge the service", async () => {
state.bootstrapError = "Service already loaded";
state.bootstrapCode = 130;
state.kickstartError = "launchctl kickstart failed: permission denied";
state.kickstartFailuresRemaining = 1;
const env = createDefaultLaunchdEnv();

View File

@@ -444,9 +444,10 @@ export async function repairLaunchAgentBootstrap(args: {
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env });
const plistPath = resolveLaunchAgentPlistPath(env);
await execLaunchctl(["enable", `${domain}/${label}`]);
const serviceTarget = `${domain}/${label}`;
await execLaunchctl(["enable", serviceTarget]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
let repairStatus: LaunchAgentBootstrapRepairResult["status"] = "repaired";
let repairStatus: "repaired" | "already-loaded" = "repaired";
if (boot.code !== 0) {
const detail = (boot.stderr || boot.stdout).trim();
const normalized = normalizeLowercaseStringOrEmpty(detail);
@@ -456,7 +457,11 @@ export async function repairLaunchAgentBootstrap(args: {
}
repairStatus = "already-loaded";
}
const kick = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
if (repairStatus === "repaired") {
return { ok: true, status: repairStatus };
}
const kick = await execLaunchctl(["kickstart", serviceTarget]);
if (kick.code !== 0) {
return {
ok: false,