From fbefbf05bd749bd70a0fa8ea8f336e11ba2c34fd Mon Sep 17 00:00:00 2001 From: Yao <364939526@qq.com> Date: Sun, 26 Apr 2026 05:10:34 +0800 Subject: [PATCH] fix(active-memory): enforce timeoutMs as hard deadline via Promise.race (#71687) Wrap runRecallSubagent() with Promise.race so maybeResolveActiveRecall returns a timeout result at the configured timeoutMs even when the embedded run has not cooperatively checked the abort signal. Late subagent rejections are caught silently to prevent unhandled promise errors. Fixes #71629 --- extensions/active-memory/index.test.ts | 38 ++++++++++++++++++++++++ extensions/active-memory/index.ts | 41 +++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index a54cacbaf4a..d0245724117 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -1292,6 +1292,44 @@ describe("active-memory plugin", () => { ).toBe(true); }); + it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => { + const CONFIGURED_TIMEOUT_MS = 200; + const MARGIN_MS = 500; + __testing.setMinimumTimeoutMsForTests(1); + api.pluginConfig = { + agents: ["main"], + timeoutMs: CONFIGURED_TIMEOUT_MS, + logging: true, + }; + plugin.register(api as unknown as OpenClawPluginApi); + // Simulate a subagent that never cooperatively checks the abort signal -- + // it just blocks for a long time. + runEmbeddedPiAgent.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ payloads: [] }), 30_000)), + ); + + const startedAt = Date.now(); + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? hard deadline test", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:hard-deadline", + messageProvider: "webchat", + }, + ); + const wallClockMs = Date.now() - startedAt; + + // The hook returns undefined for timeout results (summary is null). + expect(result).toBeUndefined(); + const infoLines = vi + .mocked(api.logger.info) + .mock.calls.map((call: unknown[]) => String(call[0])); + expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(true); + // Hard deadline: wall-clock time must be near timeoutMs, not 30s. + expect(wallClockMs).toBeLessThan(CONFIGURED_TIMEOUT_MS + MARGIN_MS); + }); + it("returns undefined instead of throwing when an unexpected error escapes prompt building", async () => { const result = await hooks.before_prompt_build( { prompt: "what should i eat? escape test", messages: undefined as never }, diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 8dfc7dc3fe8..e1bf9f3b1b0 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -1784,17 +1784,56 @@ async function maybeResolveActiveRecall(params: { } const controller = new AbortController(); + const TIMEOUT_SENTINEL = Symbol("timeout"); const timeoutId = setTimeout(() => { controller.abort(new Error(`active-memory timeout after ${params.config.timeoutMs}ms`)); }, params.config.timeoutMs); timeoutId.unref?.(); + const timeoutPromise = new Promise((resolve) => { + controller.signal.addEventListener( + "abort", + () => { + resolve(TIMEOUT_SENTINEL); + }, + { once: true }, + ); + }); + try { - const { rawReply, transcriptPath, searchDebug } = await runRecallSubagent({ + const subagentPromise = runRecallSubagent({ ...params, modelRef: resolvedModelRef, abortSignal: controller.signal, }); + // Silently catch late rejections after timeout so they don't become + // unhandled promise rejections. + subagentPromise.catch(() => undefined); + + const raceResult = await Promise.race([subagentPromise, timeoutPromise]); + + if (raceResult === TIMEOUT_SENTINEL) { + const result: ActiveRecallResult = { + status: "timeout", + elapsedMs: Date.now() - startedAt, + summary: null, + }; + if (params.config.logging) { + params.api.logger.info?.( + `${logPrefix} done status=${result.status} elapsedMs=${String(result.elapsedMs)} summaryChars=0`, + ); + } + await persistPluginStatusLines({ + api: params.api, + agentId: params.agentId, + sessionKey: params.sessionKey, + statusLine: buildPluginStatusLine({ result, config: params.config }), + searchDebug: result.searchDebug, + }); + return result; + } + + const { rawReply, transcriptPath, searchDebug } = raceResult; const summary = truncateSummary( normalizeActiveSummary(rawReply) ?? "", params.config.maxSummaryChars,