mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 04:57:09 +02:00
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:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user