mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:00:44 +00: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);
|
).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 () => {
|
it("returns undefined instead of throwing when an unexpected error escapes prompt building", async () => {
|
||||||
const result = await hooks.before_prompt_build(
|
const result = await hooks.before_prompt_build(
|
||||||
{ prompt: "what should i eat? escape test", messages: undefined as never },
|
{ prompt: "what should i eat? escape test", messages: undefined as never },
|
||||||
|
|||||||
@@ -1784,17 +1784,56 @@ async function maybeResolveActiveRecall(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
const TIMEOUT_SENTINEL = Symbol("timeout");
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
controller.abort(new Error(`active-memory timeout after ${params.config.timeoutMs}ms`));
|
controller.abort(new Error(`active-memory timeout after ${params.config.timeoutMs}ms`));
|
||||||
}, params.config.timeoutMs);
|
}, params.config.timeoutMs);
|
||||||
timeoutId.unref?.();
|
timeoutId.unref?.();
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
|
||||||
|
controller.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
resolve(TIMEOUT_SENTINEL);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rawReply, transcriptPath, searchDebug } = await runRecallSubagent({
|
const subagentPromise = runRecallSubagent({
|
||||||
...params,
|
...params,
|
||||||
modelRef: resolvedModelRef,
|
modelRef: resolvedModelRef,
|
||||||
abortSignal: controller.signal,
|
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(
|
const summary = truncateSummary(
|
||||||
normalizeActiveSummary(rawReply) ?? "",
|
normalizeActiveSummary(rawReply) ?? "",
|
||||||
params.config.maxSummaryChars,
|
params.config.maxSummaryChars,
|
||||||
|
|||||||
Reference in New Issue
Block a user