diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fad7dd17d4..9a0350749db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai browser command is sent, and reconnect stale persistent Playwright CDP sessions for safe tab-list reads without replaying mutating browser actions. Fixes #67728. +- Gateway/Linux: retry `systemctl --user enable` after a second daemon reload when the freshly written gateway unit is not visible yet on migrated systemd installs. Fixes #65184. Thanks @liushuaiiu. - Telegram: preserve exact selected quote text when sending native quote replies, and retry with legacy replies if Telegram rejects quote parameters. (#71952) Thanks @rubencu. - Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd. - Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd. diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index bcbd4fa702a..4c53a81df88 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -816,6 +816,52 @@ describe("systemd service install and uninstall", () => { }); }); + it("retries enable after reloading again when systemd cannot see the written unit yet", async () => { + await withNodeSystemdFixture(async ({ env }) => { + execFileMock + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "status"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "daemon-reload"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "enable", NODE_SERVICE); + cb( + createExecFileError("enable failed"), + "", + "Unit file openclaw-node.service does not exist.", + ); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "daemon-reload"); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "enable", NODE_SERVICE); + cb(null, "", ""); + }) + .mockImplementationOnce((_cmd, args, _opts, cb) => { + assertUserSystemctlArgs(args, "restart", NODE_SERVICE); + cb(null, "", ""); + }); + + await installSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + programArguments: ["/usr/bin/openclaw", "node", "run"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_SYSTEMD_UNIT: "openclaw-node", + }, + }); + + expect(execFileMock).toHaveBeenCalledTimes(6); + }); + }); + it("disables the OPENCLAW_SYSTEMD_UNIT override during uninstall", async () => { await withNodeSystemdFixture(async ({ env, unitPath }) => { await fs.mkdir(path.dirname(unitPath), { recursive: true }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 1a300e0f025..ad75ed6f599 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -294,6 +294,18 @@ function isSystemdUnitNotEnabled(detail: string): boolean { ); } +function isSystemdUnitMissingDetail(detail: string): boolean { + if (!detail) { + return false; + } + const normalized = normalizeLowercaseStringOrEmpty(detail); + return ( + (normalized.includes("unit file") && normalized.includes("does not exist")) || + normalized.includes("not-found") || + normalized.includes("could not be found") + ); +} + const isSystemctlBusUnavailable = isSystemdUserBusUnavailableDetail; function isSystemdUserScopeUnavailable(detail: string): boolean { @@ -541,17 +553,32 @@ export async function stageSystemdService({ async function activateSystemdService(params: { env: GatewayServiceEnv }) { const serviceName = resolveSystemdServiceName(params.env); const unitName = `${serviceName}.service`; - const reload = await execSystemctlUser(params.env, ["daemon-reload"]); + const reloadSystemd = async () => await execSystemctlUser(params.env, ["daemon-reload"]); + const reload = await reloadSystemd(); if (reload.code !== 0) { throw new Error(`systemctl daemon-reload failed: ${reload.stderr || reload.stdout}`.trim()); } - const enable = await execSystemctlUser(params.env, ["enable", unitName]); + const runAfterReloadRetry = async (action: "enable" | "restart") => { + const result = await execSystemctlUser(params.env, [action, unitName]); + if (result.code === 0 || !isSystemdUnitMissingDetail(readSystemctlDetail(result))) { + return result; + } + const retryReload = await reloadSystemd(); + if (retryReload.code !== 0) { + throw new Error( + `systemctl daemon-reload failed: ${retryReload.stderr || retryReload.stdout}`.trim(), + ); + } + return await execSystemctlUser(params.env, [action, unitName]); + }; + + const enable = await runAfterReloadRetry("enable"); if (enable.code !== 0) { throw new Error(`systemctl enable failed: ${enable.stderr || enable.stdout}`.trim()); } - const restart = await execSystemctlUser(params.env, ["restart", unitName]); + const restart = await runAfterReloadRetry("restart"); if (restart.code !== 0) { throw new Error(`systemctl restart failed: ${restart.stderr || restart.stdout}`.trim()); }