fix(update): ignore inherited launchd xpc for respawn (#85789)

This commit is contained in:
Gio Della-Libera
2026-05-23 20:42:05 -07:00
committed by GitHub
parent 6b337ff3ea
commit c074d09f1e
4 changed files with 102 additions and 9 deletions

View File

@@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai
- Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf.
- WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal.
- Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.
- Gateway/update: stop treating inherited macOS `XPC_SERVICE_NAME` values as launchd supervision during update respawn, so GUI-spawned gateways use detached respawn instead of exiting for a missing LaunchAgent. Fixes #85224. Thanks @richardmqq.
- Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.
- Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id.
- Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.

View File

@@ -86,11 +86,35 @@ describe("restartGatewayProcessWithFreshPid", () => {
expect(spawnMock).not.toHaveBeenCalled();
});
it("returns supervised when launchd hints are present on macOS (no kickstart)", () => {
it("returns supervised when OpenClaw launchd markers are present on macOS (no kickstart)", () => {
clearSupervisorHints();
expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" });
});
it("returns supervised for a real gateway launchd job without the injected marker", () => {
clearSupervisorHints();
setPlatform("darwin");
process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway";
const result = restartGatewayProcessWithFreshPid();
expect(result.mode).toBe("supervised");
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it("returns supervised for a real gateway XPC launchd job without the injected marker", () => {
clearSupervisorHints();
setPlatform("darwin");
process.env.XPC_SERVICE_NAME = "ai.openclaw.gateway";
const result = restartGatewayProcessWithFreshPid();
expect(result.mode).toBe("supervised");
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it("returns supervised on macOS when launchd label is set (no kickstart)", () => {
expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" });
});
@@ -123,12 +147,18 @@ describe("restartGatewayProcessWithFreshPid", () => {
expect(spawnMock).not.toHaveBeenCalled();
});
it("returns supervised when XPC_SERVICE_NAME is set by launchd", () => {
it("does not treat inherited XPC_SERVICE_NAME as launchd supervision", () => {
clearSupervisorHints();
setPlatform("darwin");
process.env.XPC_SERVICE_NAME = "ai.openclaw.gateway";
process.env.XPC_SERVICE_NAME = "ai.openclaw.mac";
process.env.OPENCLAW_PROFILE = "mac";
const result = restartGatewayProcessWithFreshPid();
expect(result.mode).toBe("supervised");
expect(result).toEqual({
mode: "disabled",
detail: "unmanaged: use in-process restart to keep custom supervisor PID tracking stable",
});
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
@@ -288,6 +318,29 @@ describe("respawnGatewayProcessForUpdate", () => {
);
});
it("spawns a detached update process when macOS only has inherited XPC state", () => {
clearSupervisorHints();
setPlatform("darwin");
process.env.XPC_SERVICE_NAME = "ai.openclaw.mac";
process.execArgv = [];
process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"];
spawnMock.mockReturnValue({ pid: 6161, unref: vi.fn(), kill: vi.fn() });
const result = respawnGatewayProcessForUpdate();
expect(result.mode).toBe("spawned");
expect(result.pid).toBe(6161);
expect(spawnMock).toHaveBeenCalledWith(
process.execPath,
["/repo/dist/index.js", "gateway", "run"],
{
detached: true,
env: process.env,
stdio: "inherit",
},
);
});
it("returns failed when update detached respawn throws", () => {
delete process.env.OPENCLAW_NO_RESPAWN;
clearSupervisorHints();

View File

@@ -13,12 +13,34 @@ describe("SUPERVISOR_HINT_ENV_VARS", () => {
});
describe("detectRespawnSupervisor", () => {
it("detects launchd and systemd only from non-blank platform-specific hints", () => {
expect(detectRespawnSupervisor({ LAUNCH_JOB_LABEL: " ai.openclaw.gateway " }, "darwin")).toBe(
it("detects launchd from OpenClaw's explicit marker or current gateway launchd job", () => {
expect(
detectRespawnSupervisor({ OPENCLAW_LAUNCHD_LABEL: " ai.openclaw.gateway " }, "darwin"),
).toBe("launchd");
expect(detectRespawnSupervisor({ OPENCLAW_LAUNCHD_LABEL: " " }, "darwin")).toBeNull();
expect(detectRespawnSupervisor({ LAUNCH_JOB_LABEL: "ai.openclaw.gateway" }, "darwin")).toBe(
"launchd",
);
expect(detectRespawnSupervisor({ LAUNCH_JOB_LABEL: " " }, "darwin")).toBeNull();
expect(
detectRespawnSupervisor(
{ LAUNCH_JOB_NAME: "ai.openclaw.work", OPENCLAW_PROFILE: "work" },
"darwin",
),
).toBe("launchd");
expect(detectRespawnSupervisor({ LAUNCH_JOB_LABEL: "ai.openclaw.mac" }, "darwin")).toBeNull();
expect(detectRespawnSupervisor({ XPC_SERVICE_NAME: "ai.openclaw.mac" }, "darwin")).toBeNull();
expect(
detectRespawnSupervisor(
{ XPC_SERVICE_NAME: "ai.openclaw.mac", OPENCLAW_PROFILE: "mac" },
"darwin",
),
).toBeNull();
expect(detectRespawnSupervisor({ XPC_SERVICE_NAME: "ai.openclaw.gateway" }, "darwin")).toBe(
"launchd",
);
});
it("detects systemd only from non-blank platform-specific hints", () => {
expect(detectRespawnSupervisor({ INVOCATION_ID: "abc123" }, "linux")).toBe("systemd");
expect(detectRespawnSupervisor({ JOURNAL_STREAM: "" }, "linux")).toBeNull();
});

View File

@@ -1,10 +1,15 @@
import { GATEWAY_LAUNCH_AGENT_LABEL, resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
const SUPERVISOR_HINTS = {
launchd: ["LAUNCH_JOB_LABEL", "LAUNCH_JOB_NAME", "XPC_SERVICE_NAME", "OPENCLAW_LAUNCHD_LABEL"],
launchd: ["OPENCLAW_LAUNCHD_LABEL"],
systemd: ["OPENCLAW_SYSTEMD_UNIT", "INVOCATION_ID", "SYSTEMD_EXEC_PID", "JOURNAL_STREAM"],
schtasks: ["OPENCLAW_WINDOWS_TASK_NAME"],
} as const;
export const SUPERVISOR_HINT_ENV_VARS = [
"LAUNCH_JOB_LABEL",
"LAUNCH_JOB_NAME",
"XPC_SERVICE_NAME",
...SUPERVISOR_HINTS.launchd,
...SUPERVISOR_HINTS.systemd,
...SUPERVISOR_HINTS.schtasks,
@@ -21,12 +26,24 @@ function hasAnyHint(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
});
}
function isCurrentGatewayLaunchdJob(env: NodeJS.ProcessEnv): boolean {
const expectedLabel = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE);
if (
[env.LAUNCH_JOB_LABEL, env.LAUNCH_JOB_NAME].some((value) => value?.trim() === expectedLabel)
) {
return true;
}
return env.XPC_SERVICE_NAME?.trim() === GATEWAY_LAUNCH_AGENT_LABEL;
}
export function detectRespawnSupervisor(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): RespawnSupervisor | null {
if (platform === "darwin") {
return hasAnyHint(env, SUPERVISOR_HINTS.launchd) ? "launchd" : null;
return hasAnyHint(env, SUPERVISOR_HINTS.launchd) || isCurrentGatewayLaunchdJob(env)
? "launchd"
: null;
}
if (platform === "linux") {
return hasAnyHint(env, SUPERVISOR_HINTS.systemd) ? "systemd" : null;