From b81414be45942f832d63c2cf3656ee5c69df27db Mon Sep 17 00:00:00 2001 From: Solomon Neas <41877493+solomonneas@users.noreply.github.com> Date: Fri, 8 May 2026 20:42:36 -0400 Subject: [PATCH] fix: expose safe restart deferral bypass (#78658) Expose the existing safe-restart skipDeferral escape hatch through gateway RPC and the daemon CLI, document the flag, and add restart/CLI regression coverage. Also keep CLI failure output off the cold bootstrap graph and align CLI guidance expectations needed by current CI. Co-authored-by: Solomon Neas --- CHANGELOG.md | 1 + docs/cli/daemon.md | 3 +- docs/cli/gateway.md | 6 +- src/cli/channel-auth.test.ts | 10 +- src/cli/daemon-cli/lifecycle.test.ts | 20 +++ src/cli/daemon-cli/lifecycle.ts | 14 +- .../daemon-cli/register-service-commands.ts | 1 + src/cli/daemon-cli/types.ts | 1 + src/cli/models-cli.test.ts | 2 +- src/cli/plugins-cli.install.test.ts | 8 +- src/cli/run-main.exit.test.ts | 4 +- src/entry.ts | 2 +- src/gateway/server-methods/restart.test.ts | 134 ++++++++++++++++++ src/gateway/server-methods/restart.ts | 5 + src/infra/restart-coordinator.test.ts | 60 ++++++++ src/infra/restart-coordinator.ts | 10 +- 16 files changed, 263 insertions(+), 18 deletions(-) create mode 100644 src/gateway/server-methods/restart.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9eebd2e43..4ec7fd403e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Discord/voice: make `openclaw channels capabilities --channel discord --target channel:` and `channels status --probe` audit voice-channel permissions, including auto-join targets, so missing Connect/Speak/Read Message History permissions show up before `/vc join`. - Channels CLI: make `openclaw channels list` channel-only — drop the `Auth providers (OAuth + API keys)` block (use `openclaw models auth list`), drop the per-provider usage/quota fetch and the `--no-usage` flag (use `openclaw status` or `openclaw models list`), add `--all` to surface bundled-unconfigured, catalog-not-installed, and catalog-installed-but-unconfigured channels, and render explicit `installed` / `configured` / `enabled` tags per row plus an `origin` + `installed` field in JSON. Fixes WeCom-class catalog channels disappearing from `--all` when installed on disk but not yet configured. (#78456) Thanks @sliverp. - CLI/cron: add computed `status` field to `cron list --json` and `cron show --json` output, mirroring the human-readable status column (disabled/running/ok/error/skipped/idle) so external tooling can determine job state without re-deriving it from raw state fields. (#78701) Thanks @aweiker. +- Gateway/restart: expose `skipDeferral` on the `gateway.restart.request` RPC and add `openclaw gateway restart --safe --skip-deferral` so operators can bypass the safe-restart deferral gate when a pinned task run prevents the OpenClaw-aware restart from draining. Surfaces the existing internal `scheduleGatewaySigusr1Restart({ skipDeferral })` semantics added in #71637 to a public surface, complementing `gateway.reload.deferralTimeoutMs`. Refs #76162. Thanks @solomonneas. - Discord/voice: make voice capture less choppy by extending the default post-speech silence grace to 2.5s, add `voice.captureSilenceGraceMs` for noisy Discord sessions, and tighten the spoken-output prompt around live STT fragments. Thanks @vincentkoc. - Discord/streaming: default Discord replies to progress draft previews so tool/work activity appears in one edited Discord message unless `channels.discord.streaming.mode` is set to `off`. - OpenAI: support `openai/chat-latest` as an explicit direct API-key model override for trying the moving ChatGPT Instant API alias without changing the stable default model. diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 69c5fa9f3fd..dc7a7ed61ed 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -36,7 +36,7 @@ openclaw daemon uninstall - `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `install`: `--port`, `--runtime `, `--token`, `--force`, `--json` -- `restart`: `--safe`, `--force`, `--wait `, `--json` +- `restart`: `--safe`, `--skip-deferral`, `--force`, `--wait `, `--json` - lifecycle (`uninstall|start|stop`): `--json` Notes: @@ -54,6 +54,7 @@ Notes: - On macOS, `install` keeps LaunchAgent plists owner-only and loads managed service environment values through an owner-only file and wrapper instead of serializing API keys or auth-profile env refs into `EnvironmentVariables`. - If you intentionally run multiple gateways on one host, isolate ports, config/state, and workspaces; see [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host). - `restart --safe` asks the running Gateway to preflight active work and schedule one coalesced restart after active work drains. Plain `restart` keeps the existing service-manager behavior; `--force` remains the immediate override path. +- `restart --safe --skip-deferral` runs the OpenClaw-aware safe restart but bypasses the active-work deferral gate so the Gateway emits the restart immediately even when blockers are reported. Operator escape hatch when a stuck task run pins the safe restart; requires `--safe`. ## Prefer diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 0d3bf528065..4c8bd78284a 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -110,11 +110,14 @@ openclaw gateway run ```bash openclaw gateway restart openclaw gateway restart --safe +openclaw gateway restart --safe --skip-deferral openclaw gateway restart --force ``` `openclaw gateway restart --safe` asks the running Gateway to preflight active OpenClaw work before restarting. If queued operations, reply delivery, embedded runs, or task runs are active, the Gateway reports the blockers, coalesces duplicate safe restart requests, and restarts once the active work drains. Plain `restart` keeps the existing service-manager behavior for compatibility. Use `--force` only when you explicitly want the immediate override path. +`openclaw gateway restart --safe --skip-deferral` runs the same OpenClaw-aware coordinated restart as `--safe`, but bypasses the active-work deferral gate so the Gateway emits the restart immediately even when blockers are reported. Use it as the operator escape hatch when a deferral has been pinned by a stuck task run and `--safe` alone would wait indefinitely. `--skip-deferral` requires `--safe`. + Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`. @@ -482,7 +485,7 @@ openclaw gateway restart - `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `gateway install`: `--port`, `--runtime `, `--token`, `--wrapper `, `--force`, `--json` - - `gateway restart`: `--safe`, `--force`, `--wait `, `--json` + - `gateway restart`: `--safe`, `--skip-deferral`, `--force`, `--wait `, `--json` - `gateway uninstall|start`: `--json` - `gateway stop`: `--disable`, `--json` @@ -492,6 +495,7 @@ openclaw gateway restart - On macOS, `gateway stop` uses `launchctl bootout` by default, which removes the LaunchAgent from the current boot session without persisting a disable — KeepAlive auto-recovery remains active for future crashes and `gateway start` re-enables cleanly without a manual `launchctl enable`. Pass `--disable` to persistently suppress KeepAlive and RunAtLoad so the gateway does not respawn until the next explicit `gateway start`; use this when a manual stop should survive reboots or system restarts. - `gateway restart --safe` asks the running Gateway to preflight active OpenClaw work and defer the restart until reply delivery, embedded runs, and task runs drain. `--safe` cannot be combined with `--force` or `--wait`. - `gateway restart --wait 30s` overrides the configured restart drain budget for that restart. Bare numbers are milliseconds; units such as `s`, `m`, and `h` are accepted. `--wait 0` waits indefinitely. + - `gateway restart --safe --skip-deferral` runs the OpenClaw-aware safe restart but bypasses the deferral gate so the Gateway emits the restart immediately even when blockers are reported. Operator escape hatch for stuck-task-run deferrals; requires `--safe`. - `gateway restart --force` skips the active-work drain and restarts immediately. Use it when an operator has already inspected the listed task blockers and wants the gateway back now. - Lifecycle commands accept `--json` for scripting. diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index a0a4402a347..59fdc05bd60 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -225,7 +225,7 @@ describe("channel-auth", () => { mocks.loadConfig.mockReturnValue({ channels: { whatsapp: { enabled: false } } }); await expect(runChannelLogin({}, runtime)).rejects.toThrow( - "Channel is required (no configured channels support login).", + "No configured channel supports login.", ); expect(mocks.login).not.toHaveBeenCalled(); }); @@ -313,7 +313,7 @@ describe("channel-auth", () => { ); await expect(runChannelLogin({}, runtime)).rejects.toThrow( - "multiple configured channels support login: whatsapp, zalouser", + "Multiple configured channels support login: whatsapp, zalouser.", ); expect(mocks.login).not.toHaveBeenCalled(); }); @@ -340,7 +340,7 @@ describe("channel-auth", () => { mocks.normalizeChannelId.mockImplementation(() => undefined); await expect(runChannelLogin({ channel: "bad-channel" }, runtime)).rejects.toThrow( - "Unsupported channel: bad-channel", + 'Unsupported channel "bad-channel".', ); expect(mocks.login).not.toHaveBeenCalled(); }); @@ -353,7 +353,7 @@ describe("channel-auth", () => { }); await expect(runChannelLogin({ channel: "whatsapp" }, runtime)).rejects.toThrow( - "Channel whatsapp does not support login", + 'Channel "whatsapp" does not support login.', ); }); @@ -564,7 +564,7 @@ describe("channel-auth", () => { }); await expect(runChannelLogout({ channel: "whatsapp" }, runtime)).rejects.toThrow( - "Channel whatsapp does not support logout", + 'Channel "whatsapp" does not support logout.', ); }); }); diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 2222dca5a1f..71d51080322 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -122,6 +122,7 @@ describe("runDaemonRestart health checks", () => { json?: boolean; safe?: boolean; force?: boolean; + skipDeferral?: boolean; }) => Promise; let runDaemonStop: (opts?: { json?: boolean; disable?: boolean }) => Promise; let envSnapshot: ReturnType; @@ -283,6 +284,25 @@ describe("runDaemonRestart health checks", () => { expect(runServiceRestart).toHaveBeenCalled(); }); + it("forwards --safe --skip-deferral as skipDeferral: true on the RPC", async () => { + await runDaemonRestart({ json: true, safe: true, skipDeferral: true }); + + expect(callGatewayCli).toHaveBeenCalledWith({ + method: "gateway.restart.request", + params: { reason: "gateway.restart.safe", skipDeferral: true }, + timeoutMs: 10_000, + }); + expect(runServiceRestart).not.toHaveBeenCalled(); + }); + + it("rejects --skip-deferral without --safe", async () => { + await expect(runDaemonRestart({ json: true, skipDeferral: true })).rejects.toThrow( + "--skip-deferral requires --safe", + ); + expect(callGatewayCli).not.toHaveBeenCalled(); + expect(runServiceRestart).not.toHaveBeenCalled(); + }); + it("repairs stale loaded service definitions from gateway start", async () => { repairLoadedGatewayServiceForStart.mockResolvedValue({ result: "started", diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index d0f94c620d6..57cf2a1ae6c 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -155,9 +155,14 @@ async function requestSafeGatewayRestart(opts: DaemonLifecycleOptions): Promise< if (opts.wait !== undefined) { throw new Error("--safe cannot be combined with --wait; safe restart uses gateway deferral"); } + const skipDeferral = opts.skipDeferral === true; + const params: { reason: string; skipDeferral?: true } = { reason: "gateway.restart.safe" }; + if (skipDeferral) { + params.skipDeferral = true; + } const result = await callGatewayCli({ method: "gateway.restart.request", - params: { reason: "gateway.restart.safe" }, + params, timeoutMs: 10_000, }); const message = @@ -165,7 +170,9 @@ async function requestSafeGatewayRestart(opts: DaemonLifecycleOptions): Promise< ? "safe restart request joined an existing pending gateway restart" : result.status === "deferred" ? "safe restart requested; gateway will restart after active work drains" - : "safe restart requested; gateway will restart momentarily"; + : skipDeferral + ? "safe restart requested; gateway bypassing active-work deferral" + : "safe restart requested; gateway will restart momentarily"; const payload = { ok: true, result: result.status, @@ -265,6 +272,9 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { * Throws/exits on check or restart failures. */ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promise { + if (opts.skipDeferral && !opts.safe) { + throw new Error("--skip-deferral requires --safe"); + } if (opts.safe) { return await requestSafeGatewayRestart(opts); } diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index 29ea9177d64..2526fe5084a 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -129,6 +129,7 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri .description("Restart the Gateway service (launchd/systemd/schtasks)") .option("--force", "Restart immediately without waiting for active gateway work", false) .option("--safe", "Request an OpenClaw-aware restart after active work drains", false) + .option("--skip-deferral", "Bypass the safe-restart deferral gate; requires --safe", false) .option( "--wait ", "Wait duration before forcing restart (ms, 10s, 5m; 0 waits indefinitely)", diff --git a/src/cli/daemon-cli/types.ts b/src/cli/daemon-cli/types.ts index 0876d49c93f..846d2257b50 100644 --- a/src/cli/daemon-cli/types.ts +++ b/src/cli/daemon-cli/types.ts @@ -28,6 +28,7 @@ export type DaemonLifecycleOptions = { json?: boolean; force?: boolean; safe?: boolean; + skipDeferral?: boolean; wait?: string; disable?: boolean; }; diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 8b9f07da225..252b2c08aa6 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -204,7 +204,7 @@ describe("models cli", () => { command: modelsSetImageCommand, }, ])("rejects parent --agent for models $label", async ({ args, command }) => { - await expect(runModelsCommand(args)).rejects.toThrow("does not support `--agent`"); + await expect(runModelsCommand(args)).rejects.toThrow("does not support --agent"); expect(command).not.toHaveBeenCalled(); }); diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index b12ed4a855f..cd88b480695 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -346,7 +346,7 @@ describe("plugins cli install", () => { runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), ).rejects.toThrow("__exit__:1"); - expect(runtimeErrors.at(-1)).toContain("`--link` is not supported with `--marketplace`."); + expect(runtimeErrors.at(-1)).toContain("--link is not supported with --marketplace."); expect(installPluginFromMarketplace).not.toHaveBeenCalled(); }); @@ -355,7 +355,7 @@ describe("plugins cli install", () => { runPluginsCommand(["plugins", "install", "./plugin", "--link", "--force"]), ).rejects.toThrow("__exit__:1"); - expect(runtimeErrors.at(-1)).toContain("`--force` is not supported with `--link`."); + expect(runtimeErrors.at(-1)).toContain("--force is not supported with --link."); expect(installPluginFromMarketplace).not.toHaveBeenCalled(); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); @@ -1248,7 +1248,7 @@ describe("plugins cli install", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(installPluginFromClawHub).not.toHaveBeenCalled(); - expect(runtimeErrors.at(-1)).toContain("unsupported npm: spec: missing package"); + expect(runtimeErrors.at(-1)).toContain("Unsupported npm plugin spec: missing package."); }); it("installs directly from git when git: prefix is used", async () => { @@ -1295,7 +1295,7 @@ describe("plugins cli install", () => { ).rejects.toThrow("__exit__:1"); expect(installPluginFromGitSpec).not.toHaveBeenCalled(); - expect(runtimeErrors.at(-1)).toContain("use `git:@`"); + expect(runtimeErrors.at(-1)).toContain("openclaw plugins install git:@"); }); it("passes dangerous force unsafe install to marketplace installs", async () => { diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 09b81a5f028..a5d129ed6dd 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -766,9 +766,9 @@ describe("runCli exit behavior", () => { try { expect(() => handler(new Error("boom"))).toThrow("process.exit(1)"); expect(consoleErrorSpy).toHaveBeenCalledWith( - "[openclaw] Uncaught exception:", - expect.stringContaining("boom"), + "[openclaw] OpenClaw hit an unexpected runtime error.", ); + expect(consoleErrorSpy).toHaveBeenCalledWith("[openclaw] Reason: boom"); expect(restoreTerminalStateMock).toHaveBeenCalledWith("uncaught exception", { resumeStdinIfPaused: false, }); diff --git a/src/entry.ts b/src/entry.ts index 1d18ab072d4..9978367a7ee 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -3,7 +3,6 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js"; import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js"; -import { formatCliFailureLines } from "./cli/failure-output.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { assertNotRoot } from "./cli/root-guard.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; @@ -215,6 +214,7 @@ async function runMainOrRootHelp(argv: string[]): Promise { ); await runCli(argv); } catch (error) { + const { formatCliFailureLines } = await import("./cli/failure-output.js"); for (const line of formatCliFailureLines({ title: "Could not start the CLI.", error, diff --git a/src/gateway/server-methods/restart.test.ts b/src/gateway/server-methods/restart.test.ts new file mode 100644 index 00000000000..d002852d5d6 --- /dev/null +++ b/src/gateway/server-methods/restart.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; +import { restartHandlers } from "./restart.js"; + +const requestSafeGatewayRestart = vi.hoisted(() => vi.fn()); + +vi.mock("../../infra/restart-coordinator.js", () => ({ + createSafeGatewayRestartPreflight: vi.fn(() => ({ + safe: true, + counts: { + queueSize: 0, + pendingReplies: 0, + embeddedRuns: 0, + activeTasks: 0, + totalActive: 0, + }, + blockers: [], + summary: "safe to restart now", + })), + requestSafeGatewayRestart: (opts: unknown) => requestSafeGatewayRestart(opts), +})); + +function invokeRestartRequest(params: Record) { + const respond = vi.fn(); + const handler = restartHandlers["gateway.restart.request"]; + return Promise.resolve( + handler({ + respond, + params, + // The handler only reads `params` and `respond`; remaining fields are unused. + } as unknown as Parameters[0]), + ).then(() => respond); +} + +describe("gateway.restart.request handler", () => { + it("defaults to skipDeferral: false when the param is absent", async () => { + requestSafeGatewayRestart.mockReturnValueOnce({ + ok: true, + status: "scheduled", + preflight: { safe: true, counts: {}, blockers: [], summary: "safe to restart now" }, + restart: { + ok: true, + pid: 0, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }, + }); + + await invokeRestartRequest({ reason: "operator" }); + + expect(requestSafeGatewayRestart).toHaveBeenCalledWith({ + reason: "operator", + delayMs: 0, + skipDeferral: false, + }); + }); + + it("forwards skipDeferral: true only when params.skipDeferral === true", async () => { + requestSafeGatewayRestart.mockReturnValueOnce({ + ok: true, + status: "scheduled", + preflight: { safe: false, counts: {}, blockers: [], summary: "" }, + restart: { + ok: true, + pid: 0, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }, + }); + + await invokeRestartRequest({ reason: "operator", skipDeferral: true }); + + expect(requestSafeGatewayRestart).toHaveBeenCalledWith({ + reason: "operator", + delayMs: 0, + skipDeferral: true, + }); + }); + + it("normalizes truthy non-boolean skipDeferral values to false", async () => { + requestSafeGatewayRestart.mockReturnValueOnce({ + ok: true, + status: "scheduled", + preflight: { safe: true, counts: {}, blockers: [], summary: "safe to restart now" }, + restart: { + ok: true, + pid: 0, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }, + }); + + await invokeRestartRequest({ reason: "operator", skipDeferral: "true" }); + + expect(requestSafeGatewayRestart).toHaveBeenCalledWith({ + reason: "operator", + delayMs: 0, + skipDeferral: false, + }); + }); + + it("forwards skipDeferral: false explicitly when the param is sent as false", async () => { + requestSafeGatewayRestart.mockReturnValueOnce({ + ok: true, + status: "scheduled", + preflight: { safe: true, counts: {}, blockers: [], summary: "safe to restart now" }, + restart: { + ok: true, + pid: 0, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }, + }); + + await invokeRestartRequest({ reason: "operator", skipDeferral: false }); + + expect(requestSafeGatewayRestart).toHaveBeenCalledWith({ + reason: "operator", + delayMs: 0, + skipDeferral: false, + }); + }); +}); diff --git a/src/gateway/server-methods/restart.ts b/src/gateway/server-methods/restart.ts index 7fdcabf15bf..5267ef3c125 100644 --- a/src/gateway/server-methods/restart.ts +++ b/src/gateway/server-methods/restart.ts @@ -8,11 +8,16 @@ function normalizeReason(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim().slice(0, 200) : undefined; } +function normalizeSkipDeferral(value: unknown): boolean { + return value === true; +} + export const restartHandlers: GatewayRequestHandlers = { "gateway.restart.request": async ({ respond, params }) => { const result = requestSafeGatewayRestart({ reason: normalizeReason(params.reason), delayMs: 0, + skipDeferral: normalizeSkipDeferral(params.skipDeferral), }); respond(true, result); }, diff --git a/src/infra/restart-coordinator.test.ts b/src/infra/restart-coordinator.test.ts index c96567965fc..42bb4173bb2 100644 --- a/src/infra/restart-coordinator.test.ts +++ b/src/infra/restart-coordinator.test.ts @@ -116,4 +116,64 @@ describe("safe gateway restart coordinator", () => { expect(result.status).toBe("coalesced"); }); + + it("forwards skipDeferral to scheduleGatewaySigusr1Restart and marks status scheduled", () => { + scheduleGatewaySigusr1Restart.mockReturnValueOnce({ + ok: true, + pid: 123, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }); + + const result = requestSafeGatewayRestart({ + reason: "test.skip-deferral", + skipDeferral: true, + inspect: { + getQueueSize: () => 1, + getPendingReplies: () => 0, + getEmbeddedRuns: () => 0, + getActiveTasks: () => 0, + getTaskBlockers: () => [], + }, + }); + + expect(result.status).toBe("scheduled"); + expect(result.preflight.safe).toBe(false); + expect(scheduleGatewaySigusr1Restart).toHaveBeenCalledWith({ + delayMs: 0, + reason: "test.skip-deferral", + skipDeferral: true, + }); + }); + + it("omits skipDeferral when not requested", () => { + scheduleGatewaySigusr1Restart.mockReturnValueOnce({ + ok: true, + pid: 123, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }); + + requestSafeGatewayRestart({ + reason: "test.no-skip", + inspect: { + getQueueSize: () => 0, + getPendingReplies: () => 0, + getEmbeddedRuns: () => 0, + getActiveTasks: () => 0, + getTaskBlockers: () => [], + }, + }); + + expect(scheduleGatewaySigusr1Restart).toHaveBeenCalledWith({ + delayMs: 0, + reason: "test.no-skip", + }); + }); }); diff --git a/src/infra/restart-coordinator.ts b/src/infra/restart-coordinator.ts index aa2310c7ed3..f7dd64aecfe 100644 --- a/src/infra/restart-coordinator.ts +++ b/src/infra/restart-coordinator.ts @@ -149,17 +149,25 @@ export function requestSafeGatewayRestart( opts: { reason?: string; delayMs?: number; + skipDeferral?: boolean; inspect?: Partial; } = {}, ): SafeGatewayRestartRequestResult { const preflight = createSafeGatewayRestartPreflight(opts.inspect); + const skipDeferral = opts.skipDeferral === true; const restart = scheduleGatewaySigusr1Restart({ delayMs: opts.delayMs ?? 0, reason: opts.reason ?? "gateway.restart.safe", + ...(skipDeferral ? { skipDeferral: true } : {}), }); + const status = restart.coalesced + ? "coalesced" + : skipDeferral || preflight.safe + ? "scheduled" + : "deferred"; return { ok: true, - status: restart.coalesced ? "coalesced" : preflight.safe ? "scheduled" : "deferred", + status, preflight, restart, };