























@@ -1,13 +1,15 @@
11import fs from "node:fs/promises";
22import os from "node:os";
33import path from "node:path";
4-import { describe, expect, it } from "vitest";
4+import { describe, expect, it, vi } from "vitest";
55import {
66buildGatewayInstallEntrypointCandidates as resolveGatewayInstallEntrypointCandidates,
77resolveGatewayInstallEntrypoint,
88} from "../../daemon/gateway-entrypoint.js";
99import {
1010collectMissingPluginInstallPayloads,
11+recoverInstalledLaunchAgentAfterUpdate,
12+recoverLaunchAgentAndRecheckGatewayHealth,
1113resolvePostInstallDoctorEnv,
1214shouldPrepareUpdatedInstallRestart,
1315resolveUpdatedGatewayRestartPort,
@@ -234,3 +236,202 @@ describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
234236expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "unknown" })).toBe(true);
235237});
236238});
239+describe("recoverInstalledLaunchAgentAfterUpdate", () => {
240+it("re-bootstraps an installed-but-not-loaded macOS LaunchAgent after update", async () => {
241+const service = {} as never;
242+const serviceEnv = { OPENCLAW_PROFILE: "stomme" } as NodeJS.ProcessEnv;
243+const recoveredEnv = { ...serviceEnv, OPENCLAW_PORT: "18790" } as NodeJS.ProcessEnv;
244+const readState = vi.fn(async () => ({
245+installed: true,
246+loaded: false,
247+running: false,
248+env: recoveredEnv,
249+command: null,
250+runtime: { status: "unknown", missingSupervision: true },
251+}));
252+const recover = vi.fn(async () => ({
253+result: "restarted" as const,
254+loaded: true as const,
255+message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
256+}));
257+258+await expect(
259+recoverInstalledLaunchAgentAfterUpdate({
260+ service,
261+env: serviceEnv,
262+deps: {
263+platform: "darwin",
264+readState: readState as never,
265+recover: recover as never,
266+},
267+}),
268+).resolves.toEqual({
269+attempted: true,
270+recovered: true,
271+message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
272+});
273+274+expect(readState).toHaveBeenCalledWith(service, { env: serviceEnv });
275+expect(recover).toHaveBeenCalledWith({ result: "restarted", env: recoveredEnv });
276+});
277+278+it("does not touch non-macOS service managers", async () => {
279+const readState = vi.fn();
280+const recover = vi.fn();
281+282+await expect(
283+recoverInstalledLaunchAgentAfterUpdate({
284+service: {} as never,
285+deps: {
286+platform: "linux",
287+readState: readState as never,
288+recover: recover as never,
289+},
290+}),
291+).resolves.toEqual({ attempted: false, recovered: false });
292+293+expect(readState).not.toHaveBeenCalled();
294+expect(recover).not.toHaveBeenCalled();
295+});
296+297+it("does not recover a loaded LaunchAgent", async () => {
298+const readState = vi.fn(async () => ({
299+installed: true,
300+loaded: true,
301+running: true,
302+env: { OPENCLAW_PROFILE: "stomme" } as NodeJS.ProcessEnv,
303+command: null,
304+runtime: { status: "running" },
305+}));
306+const recover = vi.fn();
307+308+await expect(
309+recoverInstalledLaunchAgentAfterUpdate({
310+service: {} as never,
311+deps: {
312+platform: "darwin",
313+readState: readState as never,
314+recover: recover as never,
315+},
316+}),
317+).resolves.toEqual({ attempted: false, recovered: false });
318+319+expect(recover).not.toHaveBeenCalled();
320+});
321+322+it("returns an explicit failed recovery state when bootstrap repair fails", async () => {
323+const readState = vi.fn(async () => ({
324+installed: true,
325+loaded: false,
326+running: false,
327+env: { OPENCLAW_PROFILE: "stomme" } as NodeJS.ProcessEnv,
328+command: null,
329+runtime: { status: "unknown", missingSupervision: true },
330+}));
331+const recover = vi.fn(async () => null);
332+333+await expect(
334+recoverInstalledLaunchAgentAfterUpdate({
335+service: {} as never,
336+deps: {
337+platform: "darwin",
338+readState: readState as never,
339+recover: recover as never,
340+},
341+}),
342+).resolves.toEqual({
343+attempted: true,
344+recovered: false,
345+detail:
346+"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
347+});
348+});
349+});
350+351+describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
352+it("does not report recovered update health until the gateway passes the post-recovery wait", async () => {
353+const service = {} as never;
354+const unhealthy = {
355+runtime: { status: "stopped" },
356+portUsage: { port: 18790, status: "free", listeners: [], hints: [] },
357+healthy: false,
358+staleGatewayPids: [],
359+waitOutcome: "stopped-free",
360+} as never;
361+const healthy = {
362+runtime: { status: "running", pid: 4242 },
363+portUsage: { port: 18790, status: "busy", listeners: [{ pid: 4242 }], hints: [] },
364+healthy: true,
365+staleGatewayPids: [],
366+gatewayVersion: "2026.5.3",
367+waitOutcome: "healthy",
368+} as never;
369+const recoverLaunchAgent = vi.fn(async () => ({
370+attempted: true as const,
371+recovered: true as const,
372+message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
373+}));
374+const waitForHealthy = vi.fn(async () => healthy);
375+376+await expect(
377+recoverLaunchAgentAndRecheckGatewayHealth({
378+health: unhealthy,
379+ service,
380+port: 18790,
381+expectedVersion: "2026.5.3",
382+env: { OPENCLAW_PROFILE: "stomme", OPENCLAW_PORT: "18790" },
383+deps: { recoverLaunchAgent, waitForHealthy },
384+}),
385+).resolves.toEqual({
386+health: healthy,
387+launchAgentRecovery: {
388+attempted: true,
389+recovered: true,
390+message:
391+"Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
392+},
393+});
394+395+expect(waitForHealthy).toHaveBeenCalledWith({
396+ service,
397+port: 18790,
398+expectedVersion: "2026.5.3",
399+env: { OPENCLAW_PROFILE: "stomme", OPENCLAW_PORT: "18790" },
400+});
401+});
402+403+it("keeps the update unhealthy when LaunchAgent repair succeeds but health does not recover", async () => {
404+const service = {} as never;
405+const unhealthySnapshot = {
406+runtime: { status: "stopped" },
407+portUsage: { port: 18790, status: "free", listeners: [], hints: [] },
408+healthy: false,
409+staleGatewayPids: [],
410+waitOutcome: "stopped-free",
411+};
412+const unhealthy = unhealthySnapshot as never;
413+const stillUnhealthy = {
414+ ...unhealthySnapshot,
415+waitOutcome: "timeout",
416+} as never;
417+const recoverLaunchAgent = vi.fn(async () => ({
418+attempted: true as const,
419+recovered: true as const,
420+message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
421+}));
422+const waitForHealthy = vi.fn(async () => stillUnhealthy);
423+424+await expect(
425+recoverLaunchAgentAndRecheckGatewayHealth({
426+health: unhealthy,
427+ service,
428+port: 18790,
429+expectedVersion: "2026.5.3",
430+deps: { recoverLaunchAgent, waitForHealthy },
431+}),
432+).resolves.toMatchObject({
433+health: { healthy: false, waitOutcome: "timeout" },
434+launchAgentRecovery: { attempted: true, recovered: true },
435+});
436+});
437+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。