fix(cli): handle scheduled gateway restarts consistently

This commit is contained in:
Peter Steinberger
2026-03-12 01:38:39 +00:00
parent 841ee24340
commit b31836317a
13 changed files with 456 additions and 41 deletions

View File

@@ -40,11 +40,12 @@ vi.mock("../../runtime.js", () => ({
}));
let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart;
let runServiceStart: typeof import("./lifecycle-core.js").runServiceStart;
let runServiceStop: typeof import("./lifecycle-core.js").runServiceStop;
describe("runServiceRestart token drift", () => {
beforeAll(async () => {
({ runServiceRestart, runServiceStop } = await import("./lifecycle-core.js"));
({ runServiceRestart, runServiceStart, runServiceStop } = await import("./lifecycle-core.js"));
});
beforeEach(() => {
@@ -196,4 +197,21 @@ describe("runServiceRestart token drift", () => {
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
it("emits scheduled when service start routes through a scheduled restart", async () => {
service.restart.mockResolvedValue({ outcome: "scheduled" });
await runServiceStart({
serviceNoun: "Gateway",
service,
renderStartHints: () => [],
opts: { json: true },
});
expect(service.isLoaded).toHaveBeenCalledTimes(1);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
});

View File

@@ -4,6 +4,7 @@ import { formatConfigIssueLines } from "../../config/issue-format.js";
import { resolveIsNixMode } from "../../config/paths.js";
import { checkTokenDrift } from "../../daemon/service-audit.js";
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
import { describeGatewayServiceRestart } from "../../daemon/service.js";
import type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
@@ -224,7 +225,20 @@ export async function runServiceStart(params: {
}
try {
await params.service.restart({ env: process.env, stdout });
const restartResult = await params.service.restart({ env: process.env, stdout });
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return;
}
} catch (err) {
const hints = params.renderStartHints();
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
@@ -318,7 +332,7 @@ export async function runServiceRestart(params: {
renderStartHints: () => string[];
opts?: DaemonLifecycleOptions;
checkTokenDrift?: boolean;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<GatewayServiceRestartResult | void>;
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
}): Promise<boolean> {
const json = Boolean(params.opts?.json);
@@ -407,22 +421,38 @@ export async function runServiceRestart(params: {
if (loaded) {
restartResult = await params.service.restart({ env: process.env, stdout });
}
if (restartResult.outcome === "scheduled") {
const message = `restart scheduled, ${params.serviceNoun.toLowerCase()} will restart momentarily`;
let restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: "scheduled",
message,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(message);
defaultRuntime.log(restartStatus.message);
}
return true;
}
if (params.postRestartCheck) {
await params.postRestartCheck({ json, stdout, warnings, fail });
const postRestartResult = await params.postRestartCheck({ json, stdout, warnings, fail });
if (postRestartResult) {
restartStatus = describeGatewayServiceRestart(params.serviceNoun, postRestartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return true;
}
}
}
let restarted = loaded;
if (loaded) {

View File

@@ -132,6 +132,7 @@ describe("runDaemonRestart health checks", () => {
programArguments: ["openclaw", "gateway", "--port", "18789"],
environment: {},
});
service.restart.mockResolvedValue({ outcome: "completed" });
runServiceRestart.mockImplementation(async (params: RestartParams) => {
const fail = (message: string, hints?: string[]) => {
@@ -204,6 +205,25 @@ describe("runDaemonRestart health checks", () => {
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(2);
});
it("skips stale-pid retry health checks when the retry restart is only scheduled", async () => {
const unhealthy: RestartHealthSnapshot = {
healthy: false,
staleGatewayPids: [1993],
runtime: { status: "stopped" },
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
};
waitForGatewayHealthyRestart.mockResolvedValueOnce(unhealthy);
terminateStaleGatewayPids.mockResolvedValue([1993]);
service.restart.mockResolvedValueOnce({ outcome: "scheduled" });
const result = await runDaemonRestart({ json: true });
expect(result).toBe(true);
expect(terminateStaleGatewayPids).toHaveBeenCalledWith([1993]);
expect(service.restart).toHaveBeenCalledTimes(1);
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1);
});
it("fails restart when gateway remains unhealthy", async () => {
const unhealthy: RestartHealthSnapshot = {
healthy: false,

View File

@@ -286,7 +286,10 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
await terminateStaleGatewayPids(health.staleGatewayPids);
await service.restart({ env: process.env, stdout });
const retryRestart = await service.restart({ env: process.env, stdout });
if (retryRestart.outcome === "scheduled") {
return retryRestart;
}
health = await waitForGatewayHealthyRestart({
service,
port: restartPort,