fix(gateway): bound QR result retention

This commit is contained in:
jesse-merhi
2026-08-12 23:15:52 +10:00
parent 44494b372c
commit 9d2642bf57
3 changed files with 42 additions and 15 deletions
@@ -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<ReturnType<SystemAgentChatEngine["pollStep"]>> | 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 () => {
+26 -6
View File
@@ -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<void> | null = null;
private persistentApplySettlement: Promise<void> | null = null;
private retainedPollReplies = new Map<string, SystemAgentChatReply>();
private retainedPollReplies = new Map<string, RetainedPollReply>();
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<SystemAgentChatReply> {
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<SystemAgentChatReply> {
this.assertActive();
const turn = this.turnQueue.then(async () => {
+1 -1
View File
@@ -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.";