

















@@ -116,6 +116,92 @@ async function runHelperWithExistingSentinel(params: {
116116return { result, sentinelPath };
117117}
118118119+async function spawnExitedPid(): Promise<number> {
120+const { spawn } =
121+await vi.importActual<typeof import("node:child_process")>("node:child_process");
122+return await new Promise<number>((resolve) => {
123+const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
124+const pid = child.pid ?? 0;
125+child.once("exit", () => resolve(pid));
126+});
127+}
128+129+async function runHelperWithCommand(params: {
130+commandArgv: string[];
131+serviceRecovery?: Record<string, unknown>;
132+pathPrepend?: string;
133+}): Promise<{ code: number }> {
134+const { execFile } =
135+await vi.importActual<typeof import("node:child_process")>("node:child_process");
136+const { startManagedServiceUpdateHandoff } = await import("./update-managed-service-handoff.js");
137+const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-handoff-recovery-test-"));
138+tempDirs.add(tmpDir);
139+140+await startManagedServiceUpdateHandoff({
141+root: tmpDir,
142+timeoutMs: 1_800_000,
143+restartDelayMs: 0,
144+parentPid: process.pid,
145+execPath: "/usr/local/bin/node",
146+argv1: "/opt/openclaw/openclaw.mjs",
147+env: {},
148+meta: { sessionKey: "agent:test:webchat:dm:user-123" },
149+});
150+151+const [, args] = spawnMock.mock.calls.at(-1) as unknown as [string, string[]];
152+const helperScriptPath = args[0] ?? "";
153+tempDirs.add(path.dirname(helperScriptPath));
154+const baseParams = JSON.parse(await fs.readFile(args[1] ?? "", "utf-8")) as Record<
155+string,
156+unknown
157+>;
158+159+const helperParamsPath = path.join(tmpDir, "helper-params.json");
160+await fs.writeFile(
161+helperParamsPath,
162+`${JSON.stringify(
163+ {
164+ ...baseParams,
165+ parentPid: await spawnExitedPid(),
166+ parentExitTimeoutMs: 5000,
167+ cwd: tmpDir,
168+ commandArgv: params.commandArgv,
169+ sentinelPath: path.join(tmpDir, "restart-sentinel.json"),
170+ logPath: path.join(tmpDir, "handoff.log"),
171+ sensitivePaths: [],
172+ ...(params.serviceRecovery ? { serviceRecovery: params.serviceRecovery } : {}),
173+ },
174+ null,
175+ 2,
176+ )}\n`,
177+);
178+179+const childEnv = {
180+ ...process.env,
181+ ...(params.pathPrepend
182+ ? { PATH: `${params.pathPrepend}${path.delimiter}${process.env.PATH ?? ""}` }
183+ : {}),
184+};
185+return await new Promise<{ code: number }>((resolve) => {
186+execFile(process.execPath, [helperScriptPath, helperParamsPath], { env: childEnv }, (err) => {
187+const childError = err as NodeJS.ErrnoException | null;
188+resolve({ code: typeof childError?.code === "number" ? childError.code : 0 });
189+});
190+});
191+}
192+193+async function writeFakeSystemctl(): Promise<{ binDir: string; recordPath: string }> {
194+const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-recovery-bin-"));
195+tempDirs.add(binDir);
196+const recordPath = path.join(binDir, "systemctl-calls.log");
197+await fs.writeFile(
198+path.join(binDir, "systemctl"),
199+`#!/bin/sh\necho "$@" >> '${recordPath}'\nexit 0\n`,
200+{ mode: 0o755 },
201+);
202+return { binDir, recordPath };
203+}
204+119205describe("managed service update handoff", () => {
120206it("strips process supervisor hints while preserving service identity for the CLI handoff", async () => {
121207const { startManagedServiceUpdateHandoff, stripSupervisorHintEnv } =
@@ -244,7 +330,12 @@ describe("managed service update handoff", () => {
244330const helperParams = JSON.parse(await fs.readFile(args[6] ?? "", "utf-8")) as {
245331commandArgv?: string[];
246332handoffId?: string;
333+serviceRecovery?: unknown;
247334};
335+expect(helperParams.serviceRecovery).toEqual({
336+kind: "systemd",
337+unit: "openclaw-gateway.service",
338+});
248339expect(helperParams.commandArgv).toEqual([
249340"/usr/local/bin/node",
250341"/opt/openclaw/openclaw.mjs",
@@ -264,6 +355,75 @@ describe("managed service update handoff", () => {
264355expect(options.env.OPENCLAW_UPDATE_RUN_HANDOFF).toBe("1");
265356});
266357358+it("starts the managed gateway service when the update command fails after handoff", async () => {
359+const { binDir, recordPath } = await writeFakeSystemctl();
360+const result = await runHelperWithCommand({
361+commandArgv: [process.execPath, "-e", "process.exit(7)"],
362+serviceRecovery: { kind: "systemd", unit: "openclaw-gateway.service" },
363+pathPrepend: binDir,
364+});
365+366+expect(result.code).toBe(7);
367+await expect(fs.readFile(recordPath, "utf-8")).resolves.toBe(
368+"--user start openclaw-gateway.service\n",
369+);
370+});
371+372+it("leaves the gateway service alone when the update command succeeds", async () => {
373+const { binDir, recordPath } = await writeFakeSystemctl();
374+const result = await runHelperWithCommand({
375+commandArgv: [process.execPath, "-e", "process.exit(0)"],
376+serviceRecovery: { kind: "systemd", unit: "openclaw-gateway.service" },
377+pathPrepend: binDir,
378+});
379+380+expect(result.code).toBe(0);
381+await expect(pathExists(recordPath)).resolves.toBe(false);
382+});
383+384+it("passes a gateway service recovery descriptor for each supervisor", async () => {
385+const { startManagedServiceUpdateHandoff } =
386+await import("./update-managed-service-handoff.js");
387+const cases = [
388+{
389+supervisor: "launchd" as const,
390+env: { OPENCLAW_LAUNCHD_LABEL: "com.example.openclaw.test", HOME: "/Users/test" },
391+expected: {
392+kind: "launchd",
393+uid: typeof process.getuid === "function" ? process.getuid() : 501,
394+label: "com.example.openclaw.test",
395+plistPath: "/Users/test/Library/LaunchAgents/com.example.openclaw.test.plist",
396+},
397+},
398+{
399+supervisor: "schtasks" as const,
400+env: { OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Test Gateway" },
401+expected: { kind: "schtasks", taskName: "OpenClaw Test Gateway" },
402+},
403+];
404+405+for (const testCase of cases) {
406+const result = await startManagedServiceUpdateHandoff({
407+root: "/tmp/openclaw",
408+timeoutMs: 1_800_000,
409+restartDelayMs: 500,
410+parentPid: 12345,
411+execPath: "/usr/local/bin/node",
412+argv1: "/opt/openclaw/openclaw.mjs",
413+supervisor: testCase.supervisor,
414+env: testCase.env,
415+meta: { sessionKey: "agent:test:webchat:dm:user-123" },
416+});
417+expect(result.status).toBe("started");
418+const [, args] = spawnMock.mock.calls.at(-1) as unknown as [string, string[]];
419+tempDirs.add(path.dirname(args[0] ?? ""));
420+const helperParams = JSON.parse(await fs.readFile(args[1] ?? "", "utf-8")) as {
421+serviceRecovery?: unknown;
422+};
423+expect(helperParams.serviceRecovery).toEqual(testCase.expected);
424+}
425+});
426+267427it("does not overwrite a restart sentinel owned by another startup task", async () => {
268428const unrelatedSentinel = {
269429version: 1,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。