diff --git a/.agents/skills/openclaw-live-updater/scripts/update-main.mjs b/.agents/skills/openclaw-live-updater/scripts/update-main.mjs index 23c66e4e6ffd..c680340f8e9e 100644 --- a/.agents/skills/openclaw-live-updater/scripts/update-main.mjs +++ b/.agents/skills/openclaw-live-updater/scripts/update-main.mjs @@ -262,6 +262,47 @@ function gatewayCliOperation(args) { return "gateway.cli"; } +function isLegacyGatewaySuspendPrepareParamsError(error) { + if ( + !(error instanceof UpdateCommandError) || + error.operation !== "gateway.suspend.prepare" || + error.status !== 1 + ) { + return false; + } + const cause = ownDataProperty(error, "cause"); + const stdout = ownDataProperty(cause, "stdout"); + if (typeof stdout !== "string" || !stdout.trim()) { + return false; + } + let payload; + try { + payload = JSON.parse(stdout.trim()); + } catch { + return false; + } + if ( + typeof payload !== "object" || + payload === null || + Array.isArray(payload) || + Object.keys(payload).length !== 2 || + payload.ok !== false + ) { + return false; + } + const requestError = payload.error; + return ( + typeof requestError === "object" && + requestError !== null && + !Array.isArray(requestError) && + Object.keys(requestError).length === 4 && + requestError.type === "gateway_request_error" && + requestError.code === "INVALID_REQUEST" && + requestError.message === "invalid gateway.suspend.prepare params" && + requestError.retryable === false + ); +} + async function runUpdateCommand(runCommand, operation, command, args, checkout, options) { try { return await runCommand(command, args, checkout, options); @@ -1845,8 +1886,8 @@ export function runBuiltGatewayCall(checkout, method, params, deployment) { /** * @param {string} checkout - * @param {(checkout: string, method: string, params: { requestId: string }, deployment: GatewayDeploymentRef | null) => string} [callGateway] - * @param {GatewayDeploymentRef | null} [deployment] + * @param {(checkout: string, method: string, params: { requestId: string, terminalPolicy?: "terminate" }, deployment: GatewayCliDeployment | null) => string} [callGateway] + * @param {GatewayCliDeployment | null} [deployment] */ export function prepareGatewaySuspension( checkout, @@ -1854,11 +1895,20 @@ export function prepareGatewaySuspension( deployment = null, ) { const requestId = `openclaw-live-updater-${randomUUID()}`; + const callPrepare = (params) => + JSON.parse(callGateway(checkout, "gateway.suspend.prepare", params, deployment)); let result; try { - result = JSON.parse( - callGateway(checkout, "gateway.suspend.prepare", { requestId }, deployment), - ); + try { + result = callPrepare({ requestId, terminalPolicy: "terminate" }); + } catch (error) { + if (!isLegacyGatewaySuspendPrepareParamsError(error)) { + throw error; + } + // Older closed schemas reject the new field before acquiring a lease. + // Retry once with preserve semantics so mixed-version updates remain safe. + result = callPrepare({ requestId }); + } } catch (error) { throw new UpdateInvariantError( "gateway_suspend_prepare_failed", @@ -2747,7 +2797,7 @@ function defaultSleep(ms) { } /** - * @param {(command: string, args: string[], checkout: string, options?: Record) => unknown | Promise} runCommand + * @param {(command: string, args: string[], checkout: string, options?: Record) => void | Promise} runCommand * @param {string} checkout * @param {string} expectedSha * @param {(ms: number) => void | Promise} [sleep] @@ -2786,7 +2836,7 @@ export async function verifyGatewayReadiness( for (let attempt = 1; attempt <= GATEWAY_READINESS_ATTEMPTS; attempt += 1) { try { if (deployment) { - markGatewayMilestones(timing, await probeMilestones(deployment), timestampAt(now)); + markGatewayMilestones(timing, probeMilestones(deployment), timestampAt(now)); } const deepRpcReadyAt = await verifyGatewayDeepRpc( runCommand, @@ -2799,7 +2849,7 @@ export async function verifyGatewayReadiness( if (deployment) { markGatewayMilestones( timing, - await probeMilestones(deployment), + probeMilestones(deployment), timestampAt(now), deepRpcReadyAt, ); diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 45c4c4f5ca65..da54e87adde8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1645,15 +1645,19 @@ public struct GatewaySuspendBlocker: Codable, Sendable { public struct GatewaySuspendPrepareParams: Codable, Sendable { public let requestid: String + public let terminalpolicy: AnyCodable? public init( - requestid: String) + requestid: String, + terminalpolicy: AnyCodable? = nil) { self.requestid = requestid + self.terminalpolicy = terminalpolicy } private enum CodingKeys: String, CodingKey { case requestid = "requestId" + case terminalpolicy = "terminalPolicy" } } diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 4db155d0c455..80baca21962c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"12cd4ca949d700a5e350532ef6358758e933f4cdd4a203b90ac184b3b726d7f0","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"016fc07ce752591ba33863e49816aede24a0e90d240205dd87c882b64c611c19","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 867a31cb4a2b..5206b78aaa7d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"69a94ce26778740b1851981f59c14d496423940977883f7c8f6894a5edc38a59","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"065fb0dd1605fb70dd75335c193f378afa8230538ce051197068e38fd24ec56d","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 8c0fd9f807e1..62b59e1384d2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"ba838118f480f9dda7e17f2aae734455bcfa9eaa04def1eecc44a953f30777cf","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"59fe18c5c65144cadbfc61d22526e1475f7afb19f56303d0a007a688242e3629","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 0b4fed03a6e8..2fdb8366afbc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"b8c908a5595a4a986a5b273c144e86118663200f5442e8089d596ca33a8db3b4","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"96431e828c380418be9f996dcf41cdebd4c8a7c6052476dbbce519ef21dd2c9c","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index fb6c7b8c857a..32fd56e42623 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"018523b2135e2e6a56d13762780497ce3c5813bf685eab9ba6759fcf15c0bd75","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"13304005d532baf3d6edccf81a594e8a874ce4ebc80174b52562a3cbf2e7a12c","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index b3e14b57aa2b..7870d1970d43 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"dba5f7a7214801fad31471070bc490fab414f0789df068c2be354f0e55175a82","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"a44717708258e2b8639874737bbc01a5ca55b0237229bf2921d7806767482156","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 4bceb94d41e9..d36eb41ff801 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"e03086edb20e4fdfd3cbe9b9c217ec65e72fad776b6ab5be7ba1232400f953be","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"e974ac587d75b3a499215151dc2874d469168268a18cc024bc5048ecadf2de3b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 7712f9c3687a..93f363e2dfc3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"cd81c27ce42d21ddd58698e008ca66bbb8ec70963bafcd3c22bb9270d7c71cdc","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"6a56259993260770aa4d78eed69f1115f218c46a12a599d429e92304df4ddd0a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 363ffef35790..cfd7fbd67364 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"97a1bec418d885b030c71fbca4d89f3e3a78f9b023ee24912316095c30e82c1b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"a7ac16bfa85588ced0e46de7f75bf694a059c2af2995de3cd3f34b907dffca25","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index c71ec1917847..12c5ad7f3fda 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"6ae2801252f1bed0c001ce3f68aa74228b6893ecba27cec90a13338cfc68d7d3","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"1456c41077c880274a40b67c96c974ec8b22736b77abe8cec9966d0725fe18d3","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 0b5c3efcb8f1..0f5286f7c055 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"93affe4ecc7ff530f12561d6ec4b465d09ecae3d7abf4ec7c3461aa0e3692e0b","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"8bb9ea4976ff3657f3207838e2de5f3ce92d8c32c10bfbe8a3508e53edd16f3e","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index dd4e477039d6..11bfad55f62d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"13439d99b5059dbc96ce7419b37a7a2001754e0c527df3c7f19b2a118929aebe","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"e8a8cbcfc3bf20a51c93896e6add73d0c6d3677a7bac87ed9537afc7880c66f9","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 44f78c8cb1a7..0d67e7c394dc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"b36fe0846bc4470774334d5e4c6fe4f17479683f5b308163fd78ef573e8c189a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"d90b89f845a5df44d023e9455ff4d1fcbe51d4780a1223d624177d0ed639b0ff","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 06083ab03058..46478da7b8c6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"55a9017c833c34ce3104399e1284599d65ade7bc410abf7053da3101cae9edab","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"e7d2b6aa537db4452e8034bb57ae31bae15e471821fff539770a681d0d80b452","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 74aeba82dc6c..ac740846107e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"97c70337f3f8ade7db7cf7b6b64091f8071fbfb914a125daa119ce97ebcbf599","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"7095620f46028ce52847f310ccac8583d69aed6223eb222b961f6012fa626ea7","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 885241a26630..d5df39a85fc0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"58c25a8013bacfcd77146d79671c04cacc429e6d6abe883fc891277710b03a76","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"6738f9212ab1ca6c481cc9f3cea782baab29d63753770db25e39d83233d4f0f7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 6703c18e6077..cba3850ae82a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"51b619ddb708736ee882a8a5eb60922441f73cb69928f0a3a9be65d4c8894cfd","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"ac368b3e61d2b5d15aeaf726b9e3816aa9bc44558a4ab7e738b4094276fb1fe5","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/gateway/external-apps.md b/docs/gateway/external-apps.md index c15cba749448..9edae0d38813 100644 --- a/docs/gateway/external-apps.md +++ b/docs/gateway/external-apps.md @@ -77,12 +77,19 @@ reconnecting; expiry reopens admission automatically. The RPC contract is: - `gateway.suspend.prepare` — `operator.admin`; params - `{ "requestId": "stable-host-operation-id" }` + `{ "requestId": "stable-host-operation-id", "terminalPolicy": "preserve" }` - `gateway.suspend.status` — `operator.read`; params `{ "suspensionId": "id-from-prepare" }` - `gateway.suspend.resume` — `operator.admin`; params `{ "suspensionId": "id-from-prepare" }` +`terminalPolicy` is optional and accepts only `"preserve"` or `"terminate"`. +Omitting it defaults to `"preserve"`, so open terminal sessions block normal +host suspension. A caller preparing an update that will terminate the Gateway +may explicitly use `"terminate"`; this ignores open process-local terminal +sessions only. Terminal persistence activity and all other tracked work still +block preparation. + IDs are trimmed, must contain a non-whitespace character, and are limited to 128 characters. A busy prepare result has `status: "busy"`, `reason`, `retryAfterMs`, `activeCount`, and `blockers`. A ready result has this shape: diff --git a/docs/gateway/restart-recovery.md b/docs/gateway/restart-recovery.md index 8f89313c8024..32a45b94b9d9 100644 --- a/docs/gateway/restart-recovery.md +++ b/docs/gateway/restart-recovery.md @@ -29,6 +29,7 @@ and what the automatic resume looks like. | Queued outbound deliveries | SQLite delivery queue | Drained after restart; undelivered replies are retried | | Scheduled (cron) jobs | SQLite cron store | Schedules persist; the scheduler re-arms on boot | | Restart continuation | SQLite restart sentinel | One-shot follow-up dispatched to the session that asked for the restart | +| Gateway terminal PTYs | Process memory | End with the old process; terminal sessions are not recovered | ## Graceful restarts drain first @@ -245,6 +246,8 @@ channels.start --params '{"channel":""}'` - Work that was never admitted: messages arriving during the drain window are rejected with an explicit restart error rather than silently queued into a dying process. +- Gateway terminal PTYs, including operator- and agent-owned terminals. They + are process-local and end when the Gateway restarts. - Standalone embedded turns cannot take over a main session with pending restart recovery because they do not share the gateway's lifecycle owner. Run the turn through the gateway or reset it there with `/new` or `/reset`. diff --git a/docs/install/updating.md b/docs/install/updating.md index d7364126a60c..465d02c9011f 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -285,7 +285,9 @@ When an automatic update is due, the campaign waits for active work to finish, then starts a one-minute countdown. Once that countdown starts, new work does not reset it or return the campaign to waiting. A 15-minute hard deadline starts the update even if work remains, using the normal restart drain and -session-recovery path. +session-recovery path. Open terminal sessions do not defer the countdown or +apply. The Gateway restart ends these process-local PTYs, and terminal sessions +are not recovered afterward. An admin can use **Hold 1 h** once to postpone the campaign and shift its hard deadline, or choose **Update now** from the sidebar update card or diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 23776f170796..8383a3ac50c4 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -417,6 +417,9 @@ Connection-owned sessions survive disconnects: a page reload, laptop sleep, or n Agent-owned sessions are not bound to a browser connection. `terminal.attach` adds each browser as a viewer without taking ownership, and closing a viewer tab detaches only that browser. Conversation-owned PTYs remain until the agent closes them, their process exits, policy disables them, or the Gateway shuts down. PTYs opened by a detached task close automatically when that task succeeds, fails, times out, is cancelled, or is lost. `terminal.list` marks each entry as connection- or agent-owned. +All Gateway terminal PTYs are process-local. A Gateway restart ends them; the +PTY sessions and their scrollback are not recovered after the new process starts. + The terminal is also available as a [full-screen terminal document](/web/urls#special-documents-and-startup-modes). The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal. ## Browser panel diff --git a/packages/gateway-protocol/src/gateway-suspend.test.ts b/packages/gateway-protocol/src/gateway-suspend.test.ts index 9f6ab3ebeff5..21237e3da603 100644 --- a/packages/gateway-protocol/src/gateway-suspend.test.ts +++ b/packages/gateway-protocol/src/gateway-suspend.test.ts @@ -1,12 +1,38 @@ +import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; -import { validateGatewaySuspendPrepareParams } from "./index.js"; +import { GatewaySuspendBlockerSchema, validateGatewaySuspendPrepareParams } from "./index.js"; describe("gateway suspension protocol", () => { it("keeps prepare params closed and bounded", () => { expect(validateGatewaySuspendPrepareParams({ requestId: "host-request" })).toBe(true); + expect( + validateGatewaySuspendPrepareParams({ + requestId: "host-request", + terminalPolicy: "preserve", + }), + ).toBe(true); + expect( + validateGatewaySuspendPrepareParams({ + requestId: "host-request", + terminalPolicy: "terminate", + }), + ).toBe(true); expect(validateGatewaySuspendPrepareParams({ requestId: " " })).toBe(false); + expect( + validateGatewaySuspendPrepareParams({ requestId: "host-request", terminalPolicy: "close" }), + ).toBe(false); expect(validateGatewaySuspendPrepareParams({ requestId: "host-request", extra: true })).toBe( false, ); }); + + it("keeps the historical terminal-session blocker wire-compatible", () => { + expect( + Value.Check(GatewaySuspendBlockerSchema, { + kind: "terminal-session", + count: 1, + message: "1 open terminal session(s)", + }), + ).toBe(true); + }); }); diff --git a/packages/gateway-protocol/src/schema/gateway-suspend.ts b/packages/gateway-protocol/src/schema/gateway-suspend.ts index 727cef43a962..2dc1b0c830c1 100644 --- a/packages/gateway-protocol/src/schema/gateway-suspend.ts +++ b/packages/gateway-protocol/src/schema/gateway-suspend.ts @@ -41,7 +41,10 @@ export const GatewaySuspendBlockerSchema = closedObject({ task: Type.Optional(GatewaySuspendTaskBlockerSchema), }); -export const GatewaySuspendPrepareParamsSchema = closedObject({ requestId: SuspensionTokenSchema }); +export const GatewaySuspendPrepareParamsSchema = closedObject({ + requestId: SuspensionTokenSchema, + terminalPolicy: Type.Optional(Type.Union([Type.Literal("preserve"), Type.Literal("terminate")])), +}); export const GatewaySuspendPrepareBusyResultSchema = closedObject({ status: Type.Literal("busy"), diff --git a/src/gateway/server-methods.suspension-admission.test.ts b/src/gateway/server-methods.suspension-admission.test.ts index 2f7766b2f1df..f7e1dbd1d906 100644 --- a/src/gateway/server-methods.suspension-admission.test.ts +++ b/src/gateway/server-methods.suspension-admission.test.ts @@ -173,12 +173,13 @@ describe("gateway request suspension admission", () => { logGateway: { warn: vi.fn() }, chatAbortControllers: new Map(), chatQueuedTurns: new Map(), + terminalSessions: { size: 2 }, } as unknown as Parameters[0]["context"]; const busy = dispatch({ method: "gateway.suspend.prepare", scope: "operator.admin", handler: prepareHandler, - requestParams: { requestId: "request-concurrent-root" }, + requestParams: { requestId: "request-concurrent-root", terminalPolicy: "terminate" }, context, }); await busy.request; @@ -201,7 +202,7 @@ describe("gateway request suspension admission", () => { method: "gateway.suspend.prepare", scope: "operator.admin", handler: prepareHandler, - requestParams: { requestId: "request-own-root-excluded" }, + requestParams: { requestId: "request-own-root-excluded", terminalPolicy: "terminate" }, context, }); await ready.request; @@ -222,6 +223,95 @@ describe("gateway request suspension admission", () => { }); }); + it("applies terminal policy at the suspension RPC boundary", async () => { + const prepareHandler = suspendHandlers["gateway.suspend.prepare"]; + expect(prepareHandler).toBeTypeOf("function"); + if (!prepareHandler) { + throw new Error("expected gateway suspension prepare handler"); + } + const context = { + cron: { + pauseScheduling: vi.fn(), + resumeScheduling: vi.fn(), + getSuspensionBlockerCount: vi.fn(() => 0), + }, + logGateway: { warn: vi.fn() }, + chatAbortControllers: new Map(), + chatQueuedTurns: new Map(), + terminalSessions: { size: 2 }, + } as unknown as Parameters[0]["context"]; + + for (const [suffix, requestParams] of [ + ["default", { requestId: "request-terminal-default" }], + ["preserve", { requestId: "request-terminal-preserve", terminalPolicy: "preserve" }], + ] as const) { + const blocked = dispatch({ + method: "gateway.suspend.prepare", + scope: "operator.admin", + handler: prepareHandler, + requestParams, + context, + }); + await blocked.request; + expect(blocked.respond, suffix).toHaveBeenCalledWith( + true, + expect.objectContaining({ + status: "busy", + activeCount: 2, + blockers: [expect.objectContaining({ kind: "terminal-session", count: 2 })], + }), + ); + } + + const ready = dispatch({ + method: "gateway.suspend.prepare", + scope: "operator.admin", + handler: prepareHandler, + requestParams: { requestId: "request-terminal-terminate", terminalPolicy: "terminate" }, + context, + }); + await ready.request; + expect(ready.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ status: "ready", activeCount: 0, blockers: [] }), + ); + const readyPayload = ready.respond.mock.calls[0]?.[1] as { suspensionId?: string } | undefined; + expect(resumeGatewaySuspend(readyPayload?.suspensionId ?? "missing")).toMatchObject({ + ok: true, + resumed: true, + }); + + context.chatAbortControllers.set("persisting", { + controller: new AbortController(), + sessionId: "session-persisting", + sessionKey: "agent:main:session-persisting", + startedAtMs: 1, + expiresAtMs: 2, + registrationCleanupRequested: true, + controlUiVisible: true, + projectSessionTerminalPending: true, + }); + const persisting = dispatch({ + method: "gateway.suspend.prepare", + scope: "operator.admin", + handler: prepareHandler, + requestParams: { + requestId: "request-terminal-persistence", + terminalPolicy: "terminate", + }, + context, + }); + await persisting.request; + expect(persisting.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + status: "busy", + activeCount: 1, + blockers: [expect.objectContaining({ kind: "terminal-persistence", count: 1 })], + }), + ); + }); + it("rejects new read and write handlers outside the suspension allowlist", async () => { const suspension = tryBeginGatewaySuspendAdmission(() => {}); expect(suspension?.commit()).toBe(true); diff --git a/src/gateway/server-methods/suspend.test.ts b/src/gateway/server-methods/suspend.test.ts index 4f5ffb907ada..9fbbcb718978 100644 --- a/src/gateway/server-methods/suspend.test.ts +++ b/src/gateway/server-methods/suspend.test.ts @@ -72,6 +72,7 @@ describe("gateway suspend handlers", () => { expect(coordinator.prepare).toHaveBeenCalledWith( expect.objectContaining({ requestId: "request-1", + terminalPolicy: "preserve", pauseScheduling: expect.any(Function), resumeScheduling: expect.any(Function), }), diff --git a/src/gateway/server-methods/suspend.ts b/src/gateway/server-methods/suspend.ts index 1ae15996457e..41cea893dacf 100644 --- a/src/gateway/server-methods/suspend.ts +++ b/src/gateway/server-methods/suspend.ts @@ -35,6 +35,7 @@ export const suspendHandlers: GatewayRequestHandlers = { const requestId = params.requestId.trim(); const result = prepareGatewaySuspend({ requestId, + terminalPolicy: params.terminalPolicy ?? "preserve", pauseScheduling: () => context.cron.pauseScheduling(), resumeScheduling: () => context.cron.resumeScheduling(), inspect: createGatewayServerActiveWorkInspectors(context), diff --git a/src/infra/gateway-active-work.ts b/src/infra/gateway-active-work.ts index 37ef2c814cbc..ebed58435edd 100644 --- a/src/infra/gateway-active-work.ts +++ b/src/infra/gateway-active-work.ts @@ -100,6 +100,7 @@ function normalizeCount(value: number): number { export function createGatewayActiveWorkSnapshot( inspectors: Partial = {}, + options: { ignoreTerminalSessions?: boolean } = {}, ): GatewayActiveWorkSnapshot { const resolved = { ...defaultInspectors, ...inspectors }; const counts: GatewayActiveWorkCounts = { @@ -118,10 +119,9 @@ export function createGatewayActiveWorkSnapshot( terminalSessions: normalizeCount(resolved.getTerminalSessions()), totalActive: 0, }; - counts.totalActive = Object.entries(counts).reduce( - (total, [key, count]) => (key === "totalActive" ? total : total + count), - 0, - ); + counts.totalActive = + Object.values(counts).reduce((total, count) => total + count, 0) - + (options.ignoreTerminalSessions ? counts.terminalSessions : 0); const blockers: GatewayActiveWorkBlocker[] = []; const add = (count: number, kind: GatewayActiveWorkBlocker["kind"], message: string) => { @@ -160,11 +160,13 @@ export function createGatewayActiveWorkSnapshot( "terminal-persistence", `${counts.terminalPersistence} pending terminal session write(s)`, ); - add( - counts.terminalSessions, - "terminal-session", - `${counts.terminalSessions} open terminal session(s)`, - ); + if (!options.ignoreTerminalSessions) { + add( + counts.terminalSessions, + "terminal-session", + `${counts.terminalSessions} open terminal session(s)`, + ); + } if (counts.activeTasks > 0) { const taskBlockers = resolved.getTaskBlockers(); diff --git a/src/infra/gateway-suspend-coordinator.test.ts b/src/infra/gateway-suspend-coordinator.test.ts index 1fd78e231bc6..1d4e56c59f58 100644 --- a/src/infra/gateway-suspend-coordinator.test.ts +++ b/src/infra/gateway-suspend-coordinator.test.ts @@ -14,7 +14,10 @@ import { markGatewayRestartDraining, resetGatewayWorkAdmission, } from "../process/gateway-work-admission.js"; -import type { GatewayActiveWorkInspectors } from "./gateway-active-work.js"; +import { + createGatewayActiveWorkSnapshot, + type GatewayActiveWorkInspectors, +} from "./gateway-active-work.js"; import { getGatewaySuspendStatus, prepareGatewaySuspend, @@ -123,6 +126,102 @@ describe("gateway suspend coordinator", () => { expect(isGatewayWorkAdmissionClosed()).toBe(false); }); + it.each([undefined, "preserve"] as const)( + "keeps terminal sessions blocking with terminal policy %s", + (terminalPolicy) => { + expect( + prepareGatewaySuspend({ + requestId: `request-terminal-${terminalPolicy ?? "default"}`, + terminalPolicy, + pauseScheduling: vi.fn(), + resumeScheduling: vi.fn(), + inspect: inspectors({ getTerminalSessions: () => 2 }), + }), + ).toEqual({ + status: "busy", + reason: "active-work", + retryAfterMs: SUSPEND_RETRY_AFTER_MS, + activeCount: 2, + blockers: [ + { + kind: "terminal-session", + count: 2, + message: "2 open terminal session(s)", + }, + ], + }); + }, + ); + + it("retains terminal diagnostics when terminal sessions are not blockers", () => { + const preserving = createGatewayActiveWorkSnapshot( + inspectors({ getTerminalSessions: () => 2 }), + ); + const ignoring = createGatewayActiveWorkSnapshot(inspectors({ getTerminalSessions: () => 2 }), { + ignoreTerminalSessions: true, + }); + + expect(preserving).toMatchObject({ + idle: false, + counts: { terminalSessions: 2, totalActive: 2 }, + blockers: [expect.objectContaining({ kind: "terminal-session", count: 2 })], + }); + expect(ignoring).toMatchObject({ + idle: true, + counts: { terminalSessions: 2, totalActive: 0 }, + blockers: [], + }); + }); + + it("prepares with terminal sessions excluded when they will be terminated", () => { + const params = { + requestId: "request-terminal-terminate", + terminalPolicy: "terminate" as const, + pauseScheduling: vi.fn(), + resumeScheduling: vi.fn(), + inspect: inspectors({ getTerminalSessions: () => 2 }), + }; + const ready = prepareGatewaySuspend(params); + expect(ready).toMatchObject({ status: "ready", activeCount: 0, blockers: [] }); + expect(prepareGatewaySuspend(params)).toMatchObject({ + status: "ready", + activeCount: 0, + blockers: [], + }); + expect(prepareGatewaySuspend({ ...params, terminalPolicy: "preserve" })).toMatchObject({ + status: "conflict", + }); + }); + + it("keeps persistence and other active work blocking under terminal termination policy", () => { + expect( + prepareGatewaySuspend({ + requestId: "request-terminal-terminate-busy", + terminalPolicy: "terminate", + pauseScheduling: vi.fn(), + resumeScheduling: vi.fn(), + inspect: inspectors({ + getQueueSize: () => 1, + getTerminalPersistence: () => 1, + getTerminalSessions: () => 2, + }), + }), + ).toEqual({ + status: "busy", + reason: "active-work", + retryAfterMs: SUSPEND_RETRY_AFTER_MS, + activeCount: 2, + blockers: [ + { kind: "queue", count: 1, message: "1 queued or active operation(s)" }, + { + kind: "terminal-persistence", + count: 1, + message: "1 pending terminal session write(s)", + }, + ], + }); + }); + it("stays busy after a background session is hidden until its process exits", () => { const session = createProcessSessionFixture({ id: "private-background-session", diff --git a/src/infra/gateway-suspend-coordinator.ts b/src/infra/gateway-suspend-coordinator.ts index 4d6286ec559b..57aae69babba 100644 --- a/src/infra/gateway-suspend-coordinator.ts +++ b/src/infra/gateway-suspend-coordinator.ts @@ -1,6 +1,7 @@ // Coordinates an atomic, refuse-only host suspension preparation lease. import { randomUUID } from "node:crypto"; import type { + GatewaySuspendPrepareParams, GatewaySuspendPrepareResult as GatewaySuspendPrepareWireResult, GatewaySuspendResumeResult as GatewaySuspendResumeWireResult, GatewaySuspendStatusResult as GatewaySuspendStatusWireResult, @@ -17,6 +18,8 @@ const GATEWAY_SUSPEND_TTL_MS = 2 * 60_000; const GATEWAY_SUSPEND_RETRY_AFTER_MS = 20_000; const GATEWAY_SCHEDULER_RECOVERY_RETRY_MS = 1_000; +type GatewaySuspendTerminalPolicy = NonNullable; + type GatewaySchedulerRecoveryResult = { status: "recovering"; reason: "scheduler-resume-failed"; @@ -49,6 +52,7 @@ type GatewaySuspendCoordinatorEntryBase = { type HeldGatewaySuspension = GatewaySuspendCoordinatorEntryBase & { kind: "held"; requestId: string; + terminalPolicy: GatewaySuspendTerminalPolicy; suspensionId: string; expiresAtMs: number; snapshot: GatewayActiveWorkSnapshot; @@ -218,6 +222,7 @@ function renewHeldSuspension(held: HeldGatewaySuspension, nowMs: number): void { /** Acquire, inspect, and either roll back immediately or hold an idle fence. */ export function prepareGatewaySuspend(params: { requestId: string; + terminalPolicy?: GatewaySuspendTerminalPolicy; pauseScheduling: () => void; resumeScheduling: () => void; inspect?: Partial; @@ -225,6 +230,10 @@ export function prepareGatewaySuspend(params: { createSuspensionId?: () => string; warn?: (message: string) => void; }): GatewaySuspendPrepareResult { + const terminalPolicy = params.terminalPolicy ?? "preserve"; + const activeWorkOptions = { + ignoreTerminalSessions: terminalPolicy === "terminate", + }; const nowMs = (params.nowMs ?? Date.now)(); const current = COORDINATOR_STATE.current; if (current?.kind === "recovering") { @@ -235,7 +244,7 @@ export function prepareGatewaySuspend(params: { return schedulerRecoveryResult(); } if (existing) { - if (existing.requestId !== params.requestId) { + if (existing.requestId !== params.requestId || existing.terminalPolicy !== terminalPolicy) { return { status: "conflict", expiresAtMs: existing.expiresAtMs }; } existing.nowMs = params.nowMs ?? Date.now; @@ -264,7 +273,7 @@ export function prepareGatewaySuspend(params: { COORDINATOR_STATE.retiredForLifecycleReset = activeEntry; }); if (!admission) { - const snapshot = createGatewayActiveWorkSnapshot(params.inspect); + const snapshot = createGatewayActiveWorkSnapshot(params.inspect, activeWorkOptions); return { status: "busy", reason: "gateway-draining", @@ -279,7 +288,7 @@ export function prepareGatewaySuspend(params: { try { params.pauseScheduling(); schedulingPaused = true; - const snapshot = createGatewayActiveWorkSnapshot(params.inspect); + const snapshot = createGatewayActiveWorkSnapshot(params.inspect, activeWorkOptions); if (!snapshot.idle) { const resumed = resumeSchedulingBeforeReopen({ owner, @@ -309,6 +318,7 @@ export function prepareGatewaySuspend(params: { const held = armExpiry({ owner, requestId: params.requestId, + terminalPolicy, suspensionId, expiresAtMs, snapshot, diff --git a/src/infra/update-campaign.test.ts b/src/infra/update-campaign.test.ts index ebc272713e8b..04a88287d37e 100644 --- a/src/infra/update-campaign.test.ts +++ b/src/infra/update-campaign.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayActiveWorkInspectors } from "./gateway-active-work.js"; import { UpdateCampaignController } from "./update-campaign.js"; -function createInspectors(readBusy: () => number): GatewayActiveWorkInspectors { +function createInspectors( + readBusy: () => number, + overrides: Partial = {}, +): GatewayActiveWorkInspectors { return { getQueueSize: readBusy, getPendingReplies: () => 0, @@ -18,6 +21,7 @@ function createInspectors(readBusy: () => number): GatewayActiveWorkInspectors { getQueuedTurns: () => 0, getTerminalPersistence: () => 0, getTerminalSessions: () => 0, + ...overrides, }; } @@ -63,6 +67,36 @@ describe("UpdateCampaignController", () => { expect(apply).toHaveBeenCalledWith({ forced: false }); }); + it("ignores open terminals while persistence and queue work still delay countdown", async () => { + const controller = createController(); + let queueSize = 0; + let terminalPersistence = 1; + const apply = vi.fn(async () => "applied" as const); + + controller.announce({ + target: { kind: "package", version: "2.0.0" }, + inspect: createInspectors(() => queueSize, { + getTerminalPersistence: () => terminalPersistence, + getTerminalSessions: () => 2, + }), + apply, + onChange: vi.fn(), + }); + expect(controller.getState()?.state).toBe("waiting-for-idle"); + + terminalPersistence = 0; + queueSize = 1; + await vi.advanceTimersByTimeAsync(5_000); + expect(controller.getState()?.state).toBe("waiting-for-idle"); + queueSize = 0; + await vi.advanceTimersByTimeAsync(5_000); + expect(controller.getState()?.state).toBe("countdown"); + + await vi.advanceTimersByTimeAsync(60_000); + expect(controller.getState()?.state).toBe("applying"); + expect(apply).toHaveBeenCalledWith({ forced: false }); + }); + it("keeps an announced countdown stable when active work begins", async () => { const controller = createController(); let busy = 0; diff --git a/src/infra/update-campaign.ts b/src/infra/update-campaign.ts index ad2a81afcec9..73f4bc5d5948 100644 --- a/src/infra/update-campaign.ts +++ b/src/infra/update-campaign.ts @@ -166,7 +166,9 @@ export class UpdateCampaignController { if (campaign.state === "waiting-for-idle") { let idle = false; try { - idle = createGatewayActiveWorkSnapshot(announcement.inspect).idle; + idle = createGatewayActiveWorkSnapshot(announcement.inspect, { + ignoreTerminalSessions: true, + }).idle; } catch { // Inspection failure must not erase the hard deadline or force an unsafe early apply. } diff --git a/test/scripts/openclaw-live-updater.test.ts b/test/scripts/openclaw-live-updater.test.ts index 70fef378ca0e..37f32532fc6f 100644 --- a/test/scripts/openclaw-live-updater.test.ts +++ b/test/scripts/openclaw-live-updater.test.ts @@ -246,6 +246,52 @@ function managedTimeoutError() { return Object.assign(new Error("managed timeout"), { code: "ETIMEDOUT" }); } +function createGatewaySuspensionCliStub( + root: string, + requestError: { code: string; message: string; retryable: boolean; type: string }, +) { + const checkout = path.join(root, "checkout"); + const entrypoint = path.join(checkout, "dist/index.js"); + const configPath = path.join(root, "openclaw.json"); + const capturePath = path.join(root, "gateway-call-stub.mjs"); + const callsPath = path.join(root, "gateway-call-params.jsonl"); + mkdirSync(path.dirname(entrypoint), { recursive: true }); + writeFileSync(entrypoint, "// built\n"); + writeFileSync(configPath, "{}\n"); + writeFileSync( + capturePath, + `import fs from "node:fs"; +const paramsIndex = process.argv.indexOf("--params"); +const params = JSON.parse(process.argv[paramsIndex + 1] ?? "{}"); +fs.appendFileSync(${JSON.stringify(callsPath)}, JSON.stringify(params) + "\\n"); +if (Object.hasOwn(params, "terminalPolicy")) { + process.stdout.write(JSON.stringify({ ok: false, error: ${JSON.stringify(requestError)} }) + "\\n"); + process.exitCode = 1; +} else { + process.stdout.write(JSON.stringify({ + status: "busy", + reason: "active-work", + retryAfterMs: 20_000, + activeCount: 1, + blockers: [{ kind: "terminal-session", count: 1, message: "1 open terminal session" }], + }) + "\\n"); +} +`, + ); + return { + callsPath, + checkout, + deployment: { + configPath, + entrypoint, + executable: process.execPath, + invocationPrefix: [capturePath], + port: 18789, + wrapperPath: null, + }, + }; +} + describe("openclaw live updater", () => { let cleanupProbeRoot = ""; @@ -1062,18 +1108,28 @@ console.log(JSON.stringify({ ok: true, channels: {} })); }); test("parses ready and busy atomic Gateway suspension responses", () => { - const deployment = { entrypoint: "/snapshot/dist/index.js" }; + const deployment = { + configPath: "/snapshot/openclaw.json", + entrypoint: "/snapshot/dist/index.js", + executable: process.execPath, + invocationPrefix: ["/snapshot/dist/index.js"], + port: 18789, + wrapperPath: null, + }; expect( prepareGatewaySuspension( "/checkout", ( _checkout: string, method: string, - params: { requestId: string }, + params: { requestId: string; terminalPolicy?: "terminate" }, selectedDeployment: unknown, ) => { expect(method).toBe("gateway.suspend.prepare"); - expect(params.requestId).toMatch(/^openclaw-live-updater-/u); + expect(params).toEqual({ + requestId: expect.stringMatching(/^openclaw-live-updater-/u), + terminalPolicy: "terminate", + }); expect(selectedDeployment).toBe(deployment); return JSON.stringify({ status: "ready", suspensionId: "suspension-1" }); }, @@ -1094,6 +1150,66 @@ console.log(JSON.stringify({ ok: true, channels: {} })); ).toMatchObject({ status: "busy", activeCount: 1 }); }); + test("retries exact legacy suspension params with preserve semantics", () => { + const root = realpathSync(tempDirs.make("openclaw-legacy-gateway-suspension-")); + const stub = createGatewaySuspensionCliStub(root, { + type: "gateway_request_error", + code: "INVALID_REQUEST", + message: "invalid gateway.suspend.prepare params", + retryable: false, + }); + + expect( + prepareGatewaySuspension(stub.checkout, runBuiltGatewayCall, stub.deployment), + ).toMatchObject({ + status: "busy", + activeCount: 1, + blockers: [{ kind: "terminal-session", count: 1 }], + }); + const calls = readFileSync(stub.callsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { requestId: string; terminalPolicy?: string }); + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual({ + requestId: expect.stringMatching(/^openclaw-live-updater-/u), + terminalPolicy: "terminate", + }); + expect(calls[1]).toEqual({ requestId: calls[0]?.requestId }); + }); + + test("does not downgrade unrelated Gateway suspension failures", () => { + const root = realpathSync(tempDirs.make("openclaw-gateway-suspension-failure-")); + const stub = createGatewaySuspensionCliStub(root, { + type: "gateway_request_error", + code: "UNAVAILABLE", + message: "gateway scheduler recovery is pending", + retryable: true, + }); + let failure: unknown; + + try { + prepareGatewaySuspension(stub.checkout, runBuiltGatewayCall, stub.deployment); + } catch (error) { + failure = error; + } + + expect(formatUpdateFailure(failure)).toMatchObject({ + error: { + code: "gateway_suspend_prepare_failed", + diagnostics: { + kind: "invariant", + cause: { + kind: "command", + operation: "gateway.suspend.prepare", + status: 1, + }, + }, + }, + }); + expect(readFileSync(stub.callsPath, "utf8").trim().split("\n")).toHaveLength(1); + }); + test("pins managed Gateway calls with a backward-compatible local overlay", () => { const root = realpathSync(tempDirs.make("openclaw-gateway-call-")); const checkout = path.join(root, "checkout"); @@ -1568,7 +1684,7 @@ console.log(JSON.stringify({ ok: true, channels: {} })); ]); }); - test("defers a stale build without stopping Gateway when atomic suspension reports active work", async () => { + test("defers a stale build without stopping Gateway for a legacy terminal blocker", async () => { const { root, mirror } = makeFixture(); mkdirSync(path.join(mirror, "node_modules")); const commands = fakeCommands(mirror); @@ -1582,7 +1698,7 @@ console.log(JSON.stringify({ ok: true, channels: {} })); reason: "active-work", retryAfterMs: 20_000, activeCount: 1, - blockers: [{ kind: "cron-run", count: 1, message: "1 active cron run(s)" }], + blockers: [{ kind: "terminal-session", count: 1, message: "1 open terminal session" }], }), }, ); @@ -1594,7 +1710,7 @@ console.log(JSON.stringify({ ok: true, channels: {} })); gatewaySuspension: { status: "busy", activeCount: 1, - blockers: [{ kind: "cron-run", count: 1 }], + blockers: [{ kind: "terminal-session", count: 1 }], }, }); expect(commands.calls).toEqual([]);