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
This commit is contained in:
Yao
2026-04-26 05:10:34 +08:00
committed by GitHub
parent 7f5789575e
commit fbefbf05bd
2 changed files with 78 additions and 1 deletions

View File

@@ -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 },

View File

@@ -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<typeof TIMEOUT_SENTINEL>((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,