diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 4d164757e80c..d67896671717 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -37,7 +37,7 @@ openclaw daemon uninstall - `status`: shows service install state (launchd/systemd/schtasks) and probes Gateway health. - `install`: installs the service; `--force` reinstalls/overwrites an existing install. - `restart --safe`: asks the running Gateway to preflight active work and schedule one coalesced restart after work drains, bounded to 5 minutes. When that budget expires, the restart is forced anyway. Plain `restart` uses the service manager directly; `--force` is the immediate override. -- `restart --safe --skip-deferral`: bypasses the active-work deferral gate so the Gateway restarts immediately even when blockers are reported. Requires `--safe`. +- `restart --safe --skip-deferral`: bypasses only the active-work deferral gate. Shutdown may still wait for pending replies to drain before the Gateway process exits. Requires `--safe`. ## Notes diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 4b65e0bf1aa3..f53bb1b0af38 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -131,7 +131,7 @@ openclaw gateway restart --wait 30s `--safe` asks the running Gateway to preflight active work and schedule one coalesced restart after that work drains. The wait is bounded to 5 minutes; when the budget expires the restart is forced. `--safe` cannot combine with `--force` or `--wait`. -`--skip-deferral` bypasses the active-work deferral gate on a safe restart, so the Gateway restarts immediately even with reported blockers. It requires `--safe` — use it when a deferral is stuck on a runaway task. +`--skip-deferral` bypasses only the safe-restart active-work deferral gate. It can move the Gateway into shutdown even while active-work blockers are reported, but the close-stage pending-reply drain still applies before the process exits. It requires `--safe` — use it when a deferral is stuck on a runaway task and reply delivery can still be allowed to settle. `--wait ` overrides the drain budget for a plain (non-safe) restart. Accepts bare milliseconds or unit suffixes `ms`, `s`, `m`, `h`, `d` (e.g. `30s`, `5m`, `1h30m`); `--wait 0` waits indefinitely. Not compatible with `--force` or `--safe`. diff --git a/src/cli/daemon-cli/lifecycle-safe-restart.test.ts b/src/cli/daemon-cli/lifecycle-safe-restart.test.ts new file mode 100644 index 000000000000..ec05944b9862 --- /dev/null +++ b/src/cli/daemon-cli/lifecycle-safe-restart.test.ts @@ -0,0 +1,71 @@ +// Safe gateway restart tests cover operator-facing acknowledgement copy. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayCli = vi.hoisted(() => vi.fn()); +const appendGatewayLifecycleAudit = vi.hoisted(() => vi.fn()); +const runtimeLog = vi.hoisted(() => vi.fn()); +const runtimeWriteJson = vi.hoisted(() => vi.fn()); + +vi.mock("../../gateway/call.js", () => ({ + callGatewayCli, +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: { + log: runtimeLog, + writeJson: runtimeWriteJson, + }, + writeRuntimeJson: (_runtime: unknown, payload: unknown) => runtimeWriteJson(payload), +})); + +vi.mock("./lifecycle-audit.js", () => ({ + appendGatewayLifecycleAudit, +})); + +describe("runSafeGatewayRestart", () => { + beforeEach(() => { + callGatewayCli.mockReset(); + appendGatewayLifecycleAudit.mockReset(); + runtimeLog.mockReset(); + runtimeWriteJson.mockReset(); + }); + + it("reports that skip-deferral still allows close-stage reply drain", async () => { + const { runSafeGatewayRestart } = await import("./lifecycle-safe-restart.js"); + callGatewayCli.mockResolvedValueOnce({ + status: "scheduled", + preflight: { + safe: false, + activeWork: { + queueSize: 1, + runningTasks: 0, + activeRequests: 0, + activeAgentRuns: 0, + pendingReplies: 2, + totalActive: 3, + }, + blockers: [{ kind: "pending-replies", count: 2, message: "2 pending reply(ies)" }], + summary: "restart deferred: 2 pending reply(ies)", + }, + restart: { pid: 123 }, + }); + + await expect( + runSafeGatewayRestart({ json: true, safe: true, skipDeferral: true }), + ).resolves.toBe(true); + + expect(callGatewayCli).toHaveBeenCalledWith({ + method: "gateway.restart.request", + params: { reason: "gateway.restart.safe", skipDeferral: true }, + timeoutMs: 10_000, + }); + expect(runtimeWriteJson).toHaveBeenCalledWith( + expect.objectContaining({ + message: + "safe restart requested; gateway bypassing active-work deferral; " + + "shutdown may still wait for pending replies to drain", + result: "scheduled", + }), + ); + }); +}); diff --git a/src/cli/daemon-cli/lifecycle-safe-restart.ts b/src/cli/daemon-cli/lifecycle-safe-restart.ts index 9050c446114b..42b0b03bcd7e 100644 --- a/src/cli/daemon-cli/lifecycle-safe-restart.ts +++ b/src/cli/daemon-cli/lifecycle-safe-restart.ts @@ -83,7 +83,8 @@ export async function runSafeGatewayRestart( ? "safe restart requested; gateway will restart after active work drains " + "(bounded wait; may force after the timeout expires)" : skipDeferral - ? "safe restart requested; gateway bypassing active-work deferral" + ? "safe restart requested; gateway bypassing active-work deferral; " + + "shutdown may still wait for pending replies to drain" : "safe restart requested; gateway will restart momentarily"; const payload = { ok: true, diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index 40c0b4e59fc9..c398f38b6aae 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -153,7 +153,11 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri "(bounded wait; may force after the timeout expires)", false, ) - .option("--skip-deferral", "Bypass the safe-restart deferral gate; requires --safe", false) + .option( + "--skip-deferral", + "Bypass the safe-restart active-work deferral gate; close-stage reply drain still applies; requires --safe", + false, + ) .option( "--wait ", "Wait duration before restart (ms, 10s, 5m; 0 waits indefinitely). " +