Skip to content

Commit 0a62a18

Browse files
committed
fix(update): hand off Linux service auto-updates
1 parent 301213a commit 0a62a18

6 files changed

Lines changed: 101 additions & 3 deletions

File tree

src/infra/process-respawn.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,18 @@ describe("respawnGatewayProcessForUpdate", () => {
342342
);
343343
});
344344

345+
it("treats Linux OpenClaw gateway service markers as supervised for update restarts", () => {
346+
clearSupervisorHints();
347+
setPlatform("linux");
348+
process.env.OPENCLAW_SERVICE_MARKER = "openclaw";
349+
process.env.OPENCLAW_SERVICE_KIND = "gateway";
350+
351+
const result = respawnGatewayProcessForUpdate();
352+
353+
expect(result).toEqual({ mode: "supervised" });
354+
expect(spawnMock).not.toHaveBeenCalled();
355+
});
356+
345357
it("returns failed when update detached respawn throws", () => {
346358
delete process.env.OPENCLAW_NO_RESPAWN;
347359
clearSupervisorHints();

src/infra/process-respawn.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ export function respawnGatewayProcessForUpdate(
104104
if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) {
105105
return { mode: "disabled", detail: "OPENCLAW_NO_RESPAWN" };
106106
}
107-
const supervisor = detectRespawnSupervisor(process.env);
107+
const supervisor = detectRespawnSupervisor(process.env, process.platform, {
108+
includeLinuxOpenClawGatewayServiceMarker: true,
109+
});
108110
if (supervisor) {
109111
if (supervisor === "schtasks") {
110112
const restart = triggerOpenClawRestart();

src/infra/supervisor-markers.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,39 @@ describe("detectRespawnSupervisor", () => {
4646
expect(detectRespawnSupervisor({ JOURNAL_STREAM: "" }, "linux")).toBeNull();
4747
});
4848

49+
it("detects Linux OpenClaw gateway service markers only for opt-in callers", () => {
50+
const gatewayServiceEnv = {
51+
OPENCLAW_SERVICE_MARKER: " openclaw ",
52+
OPENCLAW_SERVICE_KIND: " gateway ",
53+
};
54+
expect(detectRespawnSupervisor(gatewayServiceEnv, "linux")).toBeNull();
55+
expect(
56+
detectRespawnSupervisor(gatewayServiceEnv, "linux", {
57+
includeLinuxOpenClawGatewayServiceMarker: true,
58+
}),
59+
).toBe("systemd");
60+
expect(
61+
detectRespawnSupervisor(
62+
{
63+
OPENCLAW_SERVICE_MARKER: "openclaw",
64+
OPENCLAW_SERVICE_KIND: "worker",
65+
},
66+
"linux",
67+
{ includeLinuxOpenClawGatewayServiceMarker: true },
68+
),
69+
).toBeNull();
70+
expect(
71+
detectRespawnSupervisor(
72+
{
73+
OPENCLAW_SERVICE_MARKER: "other",
74+
OPENCLAW_SERVICE_KIND: "gateway",
75+
},
76+
"linux",
77+
{ includeLinuxOpenClawGatewayServiceMarker: true },
78+
),
79+
).toBeNull();
80+
});
81+
4982
it("detects scheduled-task supervision on Windows from either hint family", () => {
5083
expect(
5184
detectRespawnSupervisor({ OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway" }, "win32"),

src/infra/supervisor-markers.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,24 @@ export const SUPERVISOR_HINT_ENV_VARS = [
2222
/** Supported supervisor families that can respawn the gateway after update/restart handoff. */
2323
export type RespawnSupervisor = "launchd" | "systemd" | "schtasks";
2424

25+
export interface DetectRespawnSupervisorOptions {
26+
includeLinuxOpenClawGatewayServiceMarker?: boolean;
27+
}
28+
2529
function hasAnyHint(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
2630
return keys.some((key) => {
2731
const value = env[key];
2832
return typeof value === "string" && value.trim().length > 0;
2933
});
3034
}
3135

36+
function hasOpenClawGatewayServiceMarker(env: NodeJS.ProcessEnv): boolean {
37+
return (
38+
env.OPENCLAW_SERVICE_MARKER?.trim() === "openclaw" &&
39+
env.OPENCLAW_SERVICE_KIND?.trim() === "gateway"
40+
);
41+
}
42+
3243
function isCurrentGatewayLaunchdJob(env: NodeJS.ProcessEnv): boolean {
3344
const expectedLabel = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE);
3445
if (
@@ -43,14 +54,19 @@ function isCurrentGatewayLaunchdJob(env: NodeJS.ProcessEnv): boolean {
4354
export function detectRespawnSupervisor(
4455
env: NodeJS.ProcessEnv = process.env,
4556
platform: NodeJS.Platform = process.platform,
57+
options: DetectRespawnSupervisorOptions = {},
4658
): RespawnSupervisor | null {
4759
if (platform === "darwin") {
4860
return hasAnyHint(env, SUPERVISOR_HINTS.launchd) || isCurrentGatewayLaunchdJob(env)
4961
? "launchd"
5062
: null;
5163
}
5264
if (platform === "linux") {
53-
return hasAnyHint(env, SUPERVISOR_HINTS.systemd) ? "systemd" : null;
65+
return hasAnyHint(env, SUPERVISOR_HINTS.systemd) ||
66+
(options.includeLinuxOpenClawGatewayServiceMarker === true &&
67+
hasOpenClawGatewayServiceMarker(env))
68+
? "systemd"
69+
: null;
5470
}
5571
if (platform === "win32") {
5672
if (hasAnyHint(env, SUPERVISOR_HINTS.schtasks)) {

src/infra/update-startup.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,9 @@ describe("update-startup", () => {
537537
expect(runCommandWithTimeout).toHaveBeenCalledTimes(1);
538538
expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled();
539539
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
540+
expect(detectRespawnSupervisorMock).toHaveBeenCalledWith(process.env, process.platform, {
541+
includeLinuxOpenClawGatewayServiceMarker: true,
542+
});
540543
const [argv, options] = requireFirstRunCommandCall();
541544
expect(argv).toEqual([
542545
process.execPath,
@@ -608,6 +611,36 @@ describe("update-startup", () => {
608611
});
609612
});
610613

614+
it("uses managed systemd handoff for Linux gateway service auto-updates", async () => {
615+
mockPackageInstallStatus();
616+
mockNpmChannelTag("beta", "2.0.0-beta.1");
617+
detectRespawnSupervisorMock.mockReturnValue("systemd");
618+
619+
await runAutoUpdateCheckWithDefaults({
620+
cfg: createBetaAutoUpdateConfig(),
621+
});
622+
623+
expect(runCommandWithTimeout).not.toHaveBeenCalled();
624+
expect(detectRespawnSupervisorMock).toHaveBeenCalledWith(process.env, process.platform, {
625+
includeLinuxOpenClawGatewayServiceMarker: true,
626+
});
627+
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith(
628+
expect.objectContaining({
629+
root: "/opt/openclaw",
630+
timeoutMs: 45 * 60 * 1000,
631+
channel: "beta",
632+
restartDelayMs: 2000,
633+
supervisor: "systemd",
634+
}),
635+
);
636+
expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledWith({
637+
delayMs: 2000,
638+
reason: "update.auto",
639+
skipCooldown: true,
640+
skipDeferral: true,
641+
});
642+
});
643+
611644
it("scheduleGatewayUpdateCheck returns a cleanup function", () => {
612645
mockPackageUpdateStatus("latest", "2.0.0");
613646

src/infra/update-startup.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,9 @@ async function runAutoUpdateCommand(params: {
315315
timeoutMs: number;
316316
root?: string;
317317
}): Promise<AutoUpdateRunResult> {
318-
const supervisor = detectRespawnSupervisor(process.env, process.platform);
318+
const supervisor = detectRespawnSupervisor(process.env, process.platform, {
319+
includeLinuxOpenClawGatewayServiceMarker: true,
320+
});
319321
if (supervisor) {
320322
return await startManagedServiceAutoUpdateHandoff({
321323
channel: params.channel,

0 commit comments

Comments
 (0)