Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
43e649d919 fix: add changelog for LaunchAgent restart recovery (#39237) (thanks @scoootscooob) 2026-03-09 05:36:01 +00:00
scoootscooob
1a74d50578 fix(daemon): also enable LaunchAgent in repairLaunchAgentBootstrap
The repair/recovery path had the same missing `enable` guard as
`restartLaunchAgent`.  If launchd persists a "disabled" state after a
previous `bootout`, the `bootstrap` call in `repairLaunchAgentBootstrap`
fails silently, leaving the gateway unloaded in the recovery flow.

Add the same `enable` guard before `bootstrap` that was already applied
to `installLaunchAgent` and (in this PR) `restartLaunchAgent`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:34:14 +00:00
scoootscooob
30990d73b2 fix(daemon): enable LaunchAgent before bootstrap on restart
restartLaunchAgent was missing the launchctl enable call that
installLaunchAgent already performs. launchd can persist a "disabled"
state after bootout, causing bootstrap to silently fail and leaving the
gateway unloaded until a manual reinstall.

Fixes #39211

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:34:14 +00:00
3 changed files with 34 additions and 7 deletions

View File

@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:<id>` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.
- Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.
- Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus.
- macOS/launchd restart recovery: call `launchctl enable` before `bootstrap` in LaunchAgent restart and repair flows so persisted disabled-state jobs reload cleanly after `openclaw gateway restart`. Landed from contributor PR #39237 by @scoootscooob. Thanks @scoootscooob.
## 2026.3.7

View File

@ -156,7 +156,7 @@ describe("launchctl list detection", () => {
});
describe("launchd bootstrap repair", () => {
it("bootstraps and kickstarts the resolved label", async () => {
it("enables, bootstraps, and kickstarts the resolved label", async () => {
const env: Record<string, string | undefined> = {
HOME: "/Users/test",
OPENCLAW_PROFILE: "default",
@ -167,9 +167,23 @@ describe("launchd bootstrap repair", () => {
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
const serviceId = `${domain}/${label}`;
expect(state.launchctlCalls).toContainEqual(["bootstrap", domain, plistPath]);
expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]);
const enableIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "enable" && c[1] === serviceId,
);
const bootstrapIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath,
);
const kickstartIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId,
);
expect(enableIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
expect(kickstartIndex).toBeGreaterThanOrEqual(0);
expect(enableIndex).toBeLessThan(bootstrapIndex);
expect(bootstrapIndex).toBeLessThan(kickstartIndex);
});
});
@ -241,7 +255,7 @@ describe("launchd install", () => {
expect(plist).toContain(`<integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>`);
});
it("restarts LaunchAgent with bootout-bootstrap-kickstart order", async () => {
it("restarts LaunchAgent with bootout-enable-bootstrap-kickstart order", async () => {
const env = createDefaultLaunchdEnv();
await restartLaunchAgent({
env,
@ -251,20 +265,26 @@ describe("launchd install", () => {
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
const serviceId = `${domain}/${label}`;
const bootoutIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "bootout" && c[1] === `${domain}/${label}`,
(c) => c[0] === "bootout" && c[1] === serviceId,
);
const enableIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "enable" && c[1] === serviceId,
);
const bootstrapIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath,
);
const kickstartIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === `${domain}/${label}`,
(c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId,
);
expect(bootoutIndex).toBeGreaterThanOrEqual(0);
expect(enableIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
expect(kickstartIndex).toBeGreaterThanOrEqual(0);
expect(bootoutIndex).toBeLessThan(bootstrapIndex);
expect(bootoutIndex).toBeLessThan(enableIndex);
expect(enableIndex).toBeLessThan(bootstrapIndex);
expect(bootstrapIndex).toBeLessThan(kickstartIndex);
});

View File

@ -207,6 +207,9 @@ export async function repairLaunchAgentBootstrap(args: {
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env });
const plistPath = resolveLaunchAgentPlistPath(env);
// launchd can persist "disabled" state after bootout; clear it before bootstrap
// (matches the same guard in installLaunchAgent and restartLaunchAgent).
await execLaunchctl(["enable", `${domain}/${label}`]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
if (boot.code !== 0) {
return { ok: false, detail: (boot.stderr || boot.stdout).trim() || undefined };
@ -466,6 +469,9 @@ export async function restartLaunchAgent({
await waitForPidExit(previousPid);
}
// launchd can persist "disabled" state after bootout; clear it before bootstrap
// (matches the same guard in installLaunchAgent).
await execLaunchctl(["enable", `${domain}/${label}`]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
if (boot.code !== 0) {
const detail = (boot.stderr || boot.stdout).trim();