From 9d2642bf570265865bfcd358eac04be4e710ee93 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:15:52 +1000 Subject: [PATCH] fix(gateway): bound QR result retention --- .../server-methods/system-agent.test.ts | 23 ++++++++----- src/system-agent/chat-engine.ts | 32 +++++++++++++++---- src/system-agent/chat-wizard-host.ts | 2 +- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/gateway/server-methods/system-agent.test.ts b/src/gateway/server-methods/system-agent.test.ts index 507e45c46589..0bae50d0dfa9 100644 --- a/src/gateway/server-methods/system-agent.test.ts +++ b/src/gateway/server-methods/system-agent.test.ts @@ -15,6 +15,7 @@ import { getActiveGatewayRootWorkCount } from "../../process/gateway-work-admiss import { CommandLane } from "../../process/lanes.js"; import { defaultRuntime } from "../../runtime.js"; import { SystemAgentChatEngine } from "../../system-agent/chat-engine.js"; +import { SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS } from "../../system-agent/chat-wizard-host.js"; import { createSystemAgentVerifiedInferenceTestFixture, installSystemAgentPluginMetadataTestSnapshot, @@ -1078,10 +1079,11 @@ describe("openclaw.chat", () => { ); }); - it("does not evict a terminal QR result while audit and polling finish", async () => { + it("protects terminal QR delivery through audit, then expires abandoned retention", async () => { const ownerSettled = createDeferred(); const runnerFinished = createDeferred(); const auditStarted = createDeferred(); + const auditFinished = createDeferred(); const releaseAudit = createDeferred(); const qrEngine = new SystemAgentChatEngine( { @@ -1103,6 +1105,7 @@ describe("openclaw.chat", () => { appendAuditEntry: async () => { auditStarted.resolve(); await releaseAudit.promise; + auditFinished.resolve(); return "audit-entry"; }, }, @@ -1135,14 +1138,18 @@ describe("openclaw.chat", () => { expect(sessions.has("ordinary-1")).toBe(false); releaseAudit.resolve(); - let completed: Awaited> | undefined; - await vi.waitFor(async () => { - const reply = await qrEngine.pollStep(stepId); - expect(reply.wizardSettling).not.toBe(true); - completed = reply; - }); - expect(completed?.text).toContain("telegram is configured"); + await auditFinished.promise; + await waitOneTask(); + expect(qrEngine.hasPendingQrCode()).toBe(true); + + const retainedAt = Date.now(); + vi.spyOn(Date, "now").mockReturnValue(retainedAt + SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS + 1); expect(qrEngine.hasPendingQrCode()).toBe(false); + await expect( + callChat(makeContext(sessions), { sessionId: "after-retention" }), + ).resolves.toMatchObject({ ok: true }); + expect(disposeQr).toHaveBeenCalledOnce(); + expect(sessions.has("qr-applying")).toBe(false); }); it("resets a session on request", async () => { diff --git a/src/system-agent/chat-engine.ts b/src/system-agent/chat-engine.ts index 0f48b0fb93c9..64a90e0962b5 100644 --- a/src/system-agent/chat-engine.ts +++ b/src/system-agent/chat-engine.ts @@ -19,6 +19,7 @@ import { } from "./chat-turn-router.js"; import { ChatWizardHost, + SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS, type ChatWizardHostDependencies, type SystemAgentChatReply, } from "./chat-wizard-host.js"; @@ -54,6 +55,11 @@ export type SystemAgentChatEngineOptions = { operatorApprovalOnly?: boolean; }; +type RetainedPollReply = { + expiresAtMs: number; + reply: SystemAgentChatReply; +}; + type SystemAgentChatEngineInternals = { wizardDependencies?: ChatWizardHostDependencies; executeOperation?: typeof import("./operations.js").executeSystemAgentOperation; @@ -74,7 +80,7 @@ export class SystemAgentChatEngine { private disposed = false; private disposal: Promise | null = null; private persistentApplySettlement: Promise | null = null; - private retainedPollReplies = new Map(); + private retainedPollReplies = new Map(); private passivePollsInFlight = 0; constructor( @@ -123,6 +129,7 @@ export class SystemAgentChatEngine { } hasPendingQrCode(): boolean { + this.pruneExpiredPollReplies(); return ( this.wizard.hasPendingQrCode() || this.passivePollsInFlight > 0 || @@ -207,21 +214,23 @@ export class SystemAgentChatEngine { /** Observe a passive wizard step while keeping its continuation in the turn queue. */ async pollStep(stepId: string): Promise { this.assertActive(); + this.pruneExpiredPollReplies(); const retained = this.retainedPollReplies.get(stepId); if (retained) { this.retainedPollReplies.delete(stepId); - if (retained.text) { - this.history.push({ role: "assistant", text: retained.text }); + if (retained.reply.text) { + this.history.push({ role: "assistant", text: retained.reply.text }); } - return { ...retained }; + return { ...retained.reply }; } this.passivePollsInFlight += 1; const observation = this.turnQueue .then(async () => { this.assertActive(); + this.pruneExpiredPollReplies(); const queuedRetained = this.retainedPollReplies.get(stepId); if (queuedRetained) { - return { ...queuedRetained }; + return { ...queuedRetained.reply }; } const result = await this.router.finalizeWizardResult(await this.wizard.pollStep(stepId)); this.assertActive(); @@ -231,7 +240,10 @@ export class SystemAgentChatEngine { reply.wizardInputPending !== true && reply.wizardSettling !== true ) { - this.retainedPollReplies.set(stepId, { ...reply }); + this.retainedPollReplies.set(stepId, { + expiresAtMs: Date.now() + SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS, + reply: { ...reply }, + }); } return reply; }) @@ -267,6 +279,14 @@ export class SystemAgentChatEngine { }; } + private pruneExpiredPollReplies(nowMs = Date.now()): void { + for (const [stepId, retained] of this.retainedPollReplies) { + if (retained.expiresAtMs <= nowMs) { + this.retainedPollReplies.delete(stepId); + } + } + } + async answerWizard(answer: WizardAnswer): Promise { this.assertActive(); const turn = this.turnQueue.then(async () => { diff --git a/src/system-agent/chat-wizard-host.ts b/src/system-agent/chat-wizard-host.ts index 189737f7877c..e006b2ffb111 100644 --- a/src/system-agent/chat-wizard-host.ts +++ b/src/system-agent/chat-wizard-host.ts @@ -99,7 +99,7 @@ type ActiveWizardBridge = { }; const log = createSubsystemLogger("system-agent/chat-wizard-host"); -const SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS = 25 * 60 * 1000; +export const SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS = 25 * 60 * 1000; const WIZARD_CANCEL_HINT = "Say `cancel` to stop this setup."; const WIZARD_QR_EXPIRED_MESSAGE = "This setup QR code expired. Setup is still finishing the attempt automatically.";