diff --git a/src/gateway/server-methods/system-agent.test.ts b/src/gateway/server-methods/system-agent.test.ts index 2b9e3bc9d201..03a5aa1caaa0 100644 --- a/src/gateway/server-methods/system-agent.test.ts +++ b/src/gateway/server-methods/system-agent.test.ts @@ -1379,6 +1379,80 @@ describe("openclaw.chat", () => { ); }); + it("releases active QR capacity after an abandoned ownerless QR times out", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_800_000_000_000); + const runnerFinished = createDeferred(); + const qrEngine = new SystemAgentChatEngine( + { + verifiedInference: requireVerifiedInferenceFixture(), + deps: requireVerifiedInferenceDeps(), + supportsQrCode: true, + }, + { + wizardDependencies: { + runChannelSetupWizard: async (_channel, prompter, _beforePersistentApply, signal) => { + const owner = new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(new Error("QR owner aborted", { cause: signal.reason })), + { once: true }, + ); + }); + try { + await prompter.qrCode?.({ + title: "Link a device", + message: "Scan this QR code.", + text: "https://example.test/pair", + dismissed: owner, + }); + } finally { + runnerFinished.resolve(); + } + }, + }, + }, + ); + + try { + await expect(qrEngine.handle("connect telegram")).resolves.toMatchObject({ + step: { type: "qr" }, + }); + await vi.advanceTimersByTimeAsync(SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS); + await runnerFinished.promise; + await Promise.resolve(); + expect(qrEngine.hasPendingQrCode()).toBe(true); + await vi.advanceTimersByTimeAsync(SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS + 1); + expect(qrEngine.hasPendingQrCode()).toBe(false); + + const timedOutSession = seededSession({ + engine: qrEngine, + lastUsedAt: 0, + ownerKey: "device:timed-out-owner", + }); + const disposeTimedOut = vi.spyOn(qrEngine, "dispose"); + const sessions = new Map([["timed-out", timedOutSession]]); + for (let index = 1; index < 8; index += 1) { + const protectedSession = seededSession({ + lastUsedAt: index, + ownerKey: `device:protected-owner-${index}`, + }); + vi.spyOn(protectedSession.engine, "hasPendingQrCode").mockReturnValue(true); + sessions.set(`protected-${index}`, protectedSession); + } + stubEngineOverview(); + + await expect( + callChat(makeContext(sessions), { sessionId: "new-session" }), + ).resolves.toMatchObject({ ok: true }); + expect(disposeTimedOut).toHaveBeenCalledOnce(); + expect(sessions.has("timed-out")).toBe(false); + expect(sessions.has("new-session")).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it("bounds retained QR recovery sessions without evicting their live leases", async () => { const sessions = new Map(); for (let index = 0; index < 16; index += 1) { diff --git a/src/system-agent/chat-wizard-host.ts b/src/system-agent/chat-wizard-host.ts index d9780b0922d7..c607f52d9660 100644 --- a/src/system-agent/chat-wizard-host.ts +++ b/src/system-agent/chat-wizard-host.ts @@ -568,6 +568,12 @@ export class ChatWizardHost { SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS, ); bridge.expiryTimer.unref?.(); + this.retainPassiveQrAfterSettlement(bridge, stepId); + } + + private retainPassiveQrAfterSettlement(bridge: ActiveWizardBridge, stepId: string): void { + // The runner can still apply state after its displayed QR is gone. Start the bounded + // recovery lease only once settlement makes owner eviction safe. void bridge.session.whenSettled().then(() => { if (this.bridge === bridge && bridge.passiveQrStepId === stepId) { this.clearExpiry(bridge); @@ -606,6 +612,10 @@ export class ChatWizardHost { this.armRunnerExpiry(bridge, stepId); return; } + const stepId = bridge.step?.type === "qr" ? bridge.passiveQrStepId : undefined; + if (stepId !== undefined) { + this.retainPassiveQrAfterSettlement(bridge, stepId); + } bridge.session.cancel(); // Keep a scrubbed marker until the next queued turn observes expiry. bridge.qrExpired = true;