Skip to content

Commit 3ae7658

Browse files
committed
refactor(update): simplify refresh failure restart path
1 parent f19f22e commit 3ae7658

4 files changed

Lines changed: 312 additions & 62 deletions

File tree

src/cli/daemon-cli/restart-health.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,69 @@ describe("inspectGatewayRestart", () => {
376376
expect(snapshot.versionMismatch).toBeUndefined();
377377
});
378378

379+
it("waits for the managed service when running service proof is required", async () => {
380+
probeGateway.mockResolvedValue({
381+
ok: true,
382+
close: null,
383+
server: { version: "2026.4.24", connId: "new" },
384+
});
385+
inspectPortUsage.mockResolvedValue({
386+
port: 18789,
387+
status: "busy",
388+
listeners: [{ pid: 8000, commandLine: "openclaw-gateway" }],
389+
hints: [],
390+
});
391+
const readRuntime = vi
392+
.fn()
393+
.mockResolvedValueOnce({ status: "stopped" })
394+
.mockResolvedValue({ status: "running", pid: 8000 });
395+
396+
const { waitForGatewayHealthyRestart } = await import("./restart-health.js");
397+
const snapshot = await waitForGatewayHealthyRestart({
398+
service: { readRuntime } as unknown as GatewayService,
399+
port: 18789,
400+
expectedVersion: "2026.4.24",
401+
requireRunningService: true,
402+
attempts: 3,
403+
delayMs: 1,
404+
});
405+
406+
expect(snapshot.healthy).toBe(true);
407+
expect(snapshot.runtime.status).toBe("running");
408+
expect(snapshot.waitOutcome).toBe("healthy");
409+
expect(snapshot.elapsedMs).toBe(1);
410+
expect(sleep).toHaveBeenCalledOnce();
411+
});
412+
413+
it("times out when running service proof never arrives", async () => {
414+
probeGateway.mockResolvedValue({
415+
ok: true,
416+
close: null,
417+
server: { version: "2026.4.24", connId: "stale" },
418+
});
419+
inspectPortUsage.mockResolvedValue({
420+
port: 18789,
421+
status: "busy",
422+
listeners: [{ pid: 5151, commandLine: "openclaw-gateway" }],
423+
hints: [],
424+
});
425+
426+
const { waitForGatewayHealthyRestart } = await import("./restart-health.js");
427+
const snapshot = await waitForGatewayHealthyRestart({
428+
service: makeGatewayService({ status: "stopped" }),
429+
port: 18789,
430+
expectedVersion: "2026.4.24",
431+
requireRunningService: true,
432+
attempts: 2,
433+
delayMs: 1,
434+
});
435+
436+
expect(snapshot.healthy).toBe(true);
437+
expect(snapshot.runtime.status).toBe("stopped");
438+
expect(snapshot.waitOutcome).toBe("timeout");
439+
expect(sleep).toHaveBeenCalledTimes(2);
440+
});
441+
379442
it("accepts matching-version restart liveness when the probe lacks operator scope", async () => {
380443
probeGateway.mockResolvedValue({
381444
ok: false,

src/cli/daemon-cli/restart-health.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,7 @@ export async function waitForGatewayHealthyRestart(params: {
522522
env?: NodeJS.ProcessEnv;
523523
expectedVersion?: string | null;
524524
includeUnknownListenersAsStale?: boolean;
525+
requireRunningService?: boolean;
525526
}): Promise<GatewayRestartSnapshot> {
526527
const attempts = params.attempts ?? DEFAULT_RESTART_HEALTH_ATTEMPTS;
527528
const delayMs = params.delayMs ?? DEFAULT_RESTART_HEALTH_DELAY_MS;
@@ -544,7 +545,9 @@ export async function waitForGatewayHealthyRestart(params: {
544545
);
545546

546547
for (let attempt = 0; attempt < attempts; attempt += 1) {
547-
if (snapshot.healthy) {
548+
const healthy =
549+
snapshot.healthy && (!params.requireRunningService || snapshot.runtime.status === "running");
550+
if (healthy) {
548551
return withWaitContext(snapshot, "healthy", attempt * delayMs);
549552
}
550553
if (snapshot.activatedPluginErrors?.length) {

src/cli/update-cli.test.ts

Lines changed: 165 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5015,6 +5015,9 @@ describe("update-cli", () => {
50155015
}),
50165016
});
50175017
serviceLoaded.mockResolvedValue(true);
5018+
serviceReadCommand.mockResolvedValue({
5019+
programArguments: ["node", updatedEntrypoint, "gateway", "run"],
5020+
});
50185021
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => ({
50195022
stdout: "",
50205023
stderr:
@@ -5026,39 +5029,22 @@ describe("update-cli", () => {
50265029
killed: false,
50275030
termination: "exit",
50285031
}));
5029-
probeGateway
5030-
.mockResolvedValueOnce({
5031-
ok: true,
5032-
close: null,
5033-
server: {
5034-
version: "2026.4.23",
5035-
connId: "old-gateway",
5036-
},
5037-
auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
5038-
health: null,
5039-
status: null,
5040-
presence: null,
5041-
configSnapshot: null,
5042-
connectLatencyMs: 1,
5043-
error: null,
5044-
url: "ws://127.0.0.1:18789",
5045-
})
5046-
.mockResolvedValue({
5047-
ok: true,
5048-
close: null,
5049-
server: {
5050-
version: "2026.4.24",
5051-
connId: "updated-gateway",
5052-
},
5053-
auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
5054-
health: null,
5055-
status: null,
5056-
presence: null,
5057-
configSnapshot: null,
5058-
connectLatencyMs: 1,
5059-
error: null,
5060-
url: "ws://127.0.0.1:18789",
5061-
});
5032+
probeGateway.mockResolvedValue({
5033+
ok: true,
5034+
close: null,
5035+
server: {
5036+
version: "2026.4.24",
5037+
connId: "updated-gateway",
5038+
},
5039+
auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
5040+
health: null,
5041+
status: null,
5042+
presence: null,
5043+
configSnapshot: null,
5044+
connectLatencyMs: 1,
5045+
error: null,
5046+
url: "ws://127.0.0.1:18789",
5047+
});
50625048

50635049
await updateCommand({ yes: true });
50645050

@@ -5076,6 +5062,152 @@ describe("update-cli", () => {
50765062
).toContain("Gateway: restarted and verified.");
50775063
});
50785064

5065+
it("accepts same-version refresh failure recovery when the managed service restarts", async () => {
5066+
const updatedRoot = createCaseDir("openclaw-updated-root");
5067+
const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
5068+
const updatedPackageJson = path.join(updatedRoot, "package.json");
5069+
await fs.mkdir(updatedRoot, { recursive: true });
5070+
await fs.writeFile(
5071+
updatedPackageJson,
5072+
JSON.stringify({ name: "openclaw", version: "2026.4.24" }),
5073+
"utf8",
5074+
);
5075+
setupUpdatedRootRefresh({
5076+
entrypoints: [updatedEntrypoint],
5077+
gatewayUpdateImpl: async () =>
5078+
makeOkUpdateResult({
5079+
mode: "npm",
5080+
root: updatedRoot,
5081+
before: { version: "2026.4.24" },
5082+
after: { version: "2026.4.24" },
5083+
}),
5084+
});
5085+
pathExists.mockImplementation(
5086+
async (candidate: string) =>
5087+
candidate === updatedEntrypoint || candidate === updatedPackageJson,
5088+
);
5089+
serviceLoaded.mockResolvedValue(true);
5090+
serviceReadCommand.mockResolvedValue({
5091+
programArguments: ["node", updatedEntrypoint, "gateway", "run"],
5092+
});
5093+
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => ({
5094+
stdout: "",
5095+
stderr:
5096+
argv[1] === updatedEntrypoint && argv[2] === "gateway" && argv[3] === "install"
5097+
? "launchctl bootstrap failed"
5098+
: "",
5099+
code: argv[1] === updatedEntrypoint && argv[2] === "gateway" && argv[3] === "install" ? 1 : 0,
5100+
signal: null,
5101+
killed: false,
5102+
termination: "exit",
5103+
}));
5104+
probeGateway.mockResolvedValue({
5105+
ok: true,
5106+
close: null,
5107+
server: {
5108+
version: "2026.4.24",
5109+
connId: "matching-old-gateway",
5110+
},
5111+
auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
5112+
health: null,
5113+
status: null,
5114+
presence: null,
5115+
configSnapshot: null,
5116+
connectLatencyMs: 1,
5117+
error: null,
5118+
url: "ws://127.0.0.1:18789",
5119+
});
5120+
5121+
await updateCommand({ yes: true });
5122+
5123+
expect(gatewayCommandCall(updatedEntrypoint, "install")).toBeDefined();
5124+
expect(gatewayCommandCall(updatedEntrypoint, "restart")).toBeDefined();
5125+
expect(runRestartScript).not.toHaveBeenCalled();
5126+
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
5127+
});
5128+
5129+
it("rejects same-version refresh failure recovery from a stale service definition", async () => {
5130+
const oldRoot = createCaseDir("openclaw-old-root");
5131+
const updatedRoot = createCaseDir("openclaw-updated-root");
5132+
const oldEntrypoint = path.join(oldRoot, "dist", "entry.js");
5133+
const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
5134+
const oldPackageJson = path.join(oldRoot, "package.json");
5135+
const updatedPackageJson = path.join(updatedRoot, "package.json");
5136+
await Promise.all([
5137+
fs.mkdir(oldRoot, { recursive: true }),
5138+
fs.mkdir(updatedRoot, { recursive: true }),
5139+
]);
5140+
await Promise.all([
5141+
fs.writeFile(
5142+
oldPackageJson,
5143+
JSON.stringify({ name: "openclaw", version: "2026.4.24" }),
5144+
"utf8",
5145+
),
5146+
fs.writeFile(
5147+
updatedPackageJson,
5148+
JSON.stringify({ name: "openclaw", version: "2026.4.24" }),
5149+
"utf8",
5150+
),
5151+
]);
5152+
setupUpdatedRootRefresh({
5153+
entrypoints: [oldEntrypoint, updatedEntrypoint],
5154+
gatewayUpdateImpl: async () =>
5155+
makeOkUpdateResult({
5156+
mode: "npm",
5157+
root: updatedRoot,
5158+
before: { version: "2026.4.24" },
5159+
after: { version: "2026.4.24" },
5160+
}),
5161+
});
5162+
pathExists.mockImplementation(async (candidate: string) =>
5163+
[oldEntrypoint, updatedEntrypoint, oldPackageJson, updatedPackageJson].includes(candidate),
5164+
);
5165+
serviceLoaded.mockResolvedValue(true);
5166+
serviceReadCommand.mockResolvedValue({
5167+
programArguments: ["node", oldEntrypoint, "gateway", "run"],
5168+
});
5169+
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => ({
5170+
stdout: "",
5171+
stderr:
5172+
argv[1] === updatedEntrypoint && argv[2] === "gateway" && argv[3] === "install"
5173+
? "launchctl bootstrap failed"
5174+
: "",
5175+
code: argv[1] === updatedEntrypoint && argv[2] === "gateway" && argv[3] === "install" ? 1 : 0,
5176+
signal: null,
5177+
killed: false,
5178+
termination: "exit",
5179+
}));
5180+
probeGateway.mockResolvedValue({
5181+
ok: true,
5182+
close: null,
5183+
server: {
5184+
version: "2026.4.24",
5185+
connId: "matching-old-service",
5186+
},
5187+
auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
5188+
health: null,
5189+
status: null,
5190+
presence: null,
5191+
configSnapshot: null,
5192+
connectLatencyMs: 1,
5193+
error: null,
5194+
url: "ws://127.0.0.1:18789",
5195+
});
5196+
5197+
await updateCommand({ yes: true });
5198+
5199+
expect(gatewayCommandCall(updatedEntrypoint, "install")).toBeDefined();
5200+
expect(gatewayCommandCall(updatedEntrypoint, "restart")).toBeDefined();
5201+
expect(runRestartScript).not.toHaveBeenCalled();
5202+
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
5203+
expect(
5204+
vi
5205+
.mocked(defaultRuntime.log)
5206+
.mock.calls.map((call) => String(call[0]))
5207+
.join("\n"),
5208+
).toContain("did not point at the updated install");
5209+
});
5210+
50795211
it("fails a JSON package update when fallback restart leaves the old gateway running", async () => {
50805212
const updatedRoot = createCaseDir("openclaw-updated-root");
50815213
const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");

0 commit comments

Comments
 (0)