From fbecf73b4fc8306949d2bb3f2b4c0e2d5bc78cb3 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:35:39 +1000 Subject: [PATCH] fix(gateway): retain settled cleanup failures --- docs/gateway/clients.md | 4 +++ .../system-agent-session-lifecycle.test.ts | 27 +++++++++++++++++++ .../system-agent-session-lifecycle.ts | 15 +++++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/gateway/clients.md b/docs/gateway/clients.md index d5940b5e09dc..9933e1991fae 100644 --- a/docs/gateway/clients.md +++ b/docs/gateway/clients.md @@ -136,6 +136,10 @@ snapshot, so re-read them on every reconnect. ### Present system-agent QR codes +System-agent setup is administrative. Request `operator.admin` when pairing the +device; every `openclaw.chat` call in this flow, including setup, polling, +cancellation, and reset, requires that scope. + Advertise `GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE` only when the client can render a QR image. A capable `openclaw.chat` session can then receive a QR `step` through the same wizard-step contract used for other setup controls: diff --git a/src/gateway/server-methods/system-agent-session-lifecycle.test.ts b/src/gateway/server-methods/system-agent-session-lifecycle.test.ts index b7a04d50ae2e..b2c11557678a 100644 --- a/src/gateway/server-methods/system-agent-session-lifecycle.test.ts +++ b/src/gateway/server-methods/system-agent-session-lifecycle.test.ts @@ -216,4 +216,31 @@ describe("system-agent session lifecycle", () => { await retirementSettlement; expect(sessions.size).toBe(0); }); + + it("retains a settled initialization cleanup failure until retirement reports it once", async () => { + const requestError = new Error("request failed before retirement"); + const cleanupError = new Error("cleanup failed before retirement"); + const sessions = new Map() as Sessions; + const wizardSessions = new Map() as GatewayRequestContext["wizardSessions"]; + const initialization = initializeSystemAgentSession( + sessions, + "failed-session", + async ({ ownEngine }) => { + ownEngine( + session("device:one", async () => { + throw cleanupError; + }).engine, + ); + throw requestError; + }, + ); + + await expect(initialization).rejects.toBe(requestError); + await expect( + retireAndDisposeSystemAgentSessions({ sessions, wizardSessions }), + ).rejects.toMatchObject({ errors: [cleanupError] }); + await expect( + retireAndDisposeSystemAgentSessions({ sessions, wizardSessions }), + ).resolves.toBeUndefined(); + }); }); diff --git a/src/gateway/server-methods/system-agent-session-lifecycle.ts b/src/gateway/server-methods/system-agent-session-lifecycle.ts index e80d3b08ee3e..74950e8e4e3b 100644 --- a/src/gateway/server-methods/system-agent-session-lifecycle.ts +++ b/src/gateway/server-methods/system-agent-session-lifecycle.ts @@ -14,6 +14,7 @@ type ApprovalManager = NonNullable(); const pendingSessionSettlements = new WeakMap>>(); +const settledInitializationCleanupFailures = new WeakMap(); const retiredWizardStores = new WeakSet(); const pendingWizardAdmissions = new WeakMap< WizardSessions, @@ -77,8 +78,14 @@ export function initializeSystemAgentSession( await uncommittedEngine?.dispose(); settlement.resolve(); } catch (error) { - // Cleanup belongs to the retiring owner, while the request keeps its original outcome. - settlement.reject(error); + const cleanupError = toErrorObject(error, "Unknown system-agent cleanup failure"); + if (!retiredStores.has(sessions)) { + const failures = settledInitializationCleanupFailures.get(sessions) ?? []; + failures.push(cleanupError); + settledInitializationCleanupFailures.set(sessions, failures); + } + // Retirement already snapshots an in-flight lease; only earlier failures need escrow. + settlement.reject(cleanupError); } } })(); @@ -209,8 +216,12 @@ export function retireAndDisposeSystemAgentSessions(params: { }): Promise { retiredStores.add(params.sessions); retiredWizardStores.add(params.wizardSessions); + const settledInitializationFailures = + settledInitializationCleanupFailures.get(params.sessions) ?? []; + settledInitializationCleanupFailures.delete(params.sessions); const disposals: Promise[] = [ ...(pendingSessionSettlements.get(params.sessions) ?? []), + ...settledInitializationFailures.map((error) => Promise.reject(error)), ...(pendingWizardAdmissions.get(params.wizardSessions) ?? []), ]; for (const [sessionId, session] of params.sessions) {