From 6b255b4dec0346ede6c86fceccdef8f30aab0432 Mon Sep 17 00:00:00 2001 From: Kris Wu <32388289+mpz4life@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:30:13 +0800 Subject: [PATCH] fix(agents): prevent unhandled rejection when compaction retry times out [AI] (#57451) * fix(agents): prevent unhandled rejection when compaction retry times out * fix(agents): preserve compaction retry wait errors * chore(changelog): add compaction retry timeout entry --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + ...compaction-retry-aggregate-timeout.test.ts | 40 +++++++++++++++++++ .../run/compaction-retry-aggregate-timeout.ts | 14 +++++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8baf1028bfa..703cb94a601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai - Status: fix cache hit rate exceeding 100% by deriving denominator from prompt-side token fields instead of potentially undersized totalTokens. Fixes #26643. - Config/update: stop `openclaw doctor` write-backs from persisting plugin-injected channel defaults, so `openclaw update` no longer seeds config keys that later break service refresh validation. (#56834) Thanks @openperf. - Agents/Anthropic failover: treat Anthropic `api_error` payloads with `An unexpected error occurred while processing the response` as transient so retry/fallback can engage instead of surfacing a terminal failure. (#57441) Thanks @zijiess and @vincentkoc. +- Agents/compaction: keep late compaction-retry rejections handled after the aggregate timeout path wins without swallowing real pre-timeout wait failures, so timed-out retries no longer surface an unhandled rejection on later unsubscribe. (#57451) Thanks @mpz4life and @vincentkoc. ## 2026.3.28 diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts index e5f02cecf0c..648511e39b8 100644 --- a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts @@ -116,6 +116,46 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { }); }); + it("propagates immediate waitForCompactionRetry failures", async () => { + await withFakeTimers(async () => { + const waitError = new Error("compaction wait failed"); + const waitForCompactionRetry = vi.fn(async () => { + throw waitError; + }); + const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); + + await expect(waitForCompactionRetryWithAggregateTimeout(params)).rejects.toThrow( + "compaction wait failed", + ); + + expectClearedTimeoutState(params.onTimeout, false); + }); + }); + + it("handles waitForCompactionRetry rejection after timeout wins", async () => { + await withFakeTimers(async () => { + let rejectWait: ((error: Error) => void) | undefined; + const waitForCompactionRetry = vi.fn( + async () => + await new Promise((_resolve, reject) => { + rejectWait = reject; + }), + ); + const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); + + const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); + + await vi.advanceTimersByTimeAsync(60_000); + const result = await resultPromise; + + rejectWait?.(new Error("cancelled after timeout")); + await Promise.resolve(); + + expect(result.timedOut).toBe(true); + expectClearedTimeoutState(params.onTimeout, true); + }); + }); + it("propagates abort errors from abortable and clears timer", async () => { await withFakeTimers(async () => { const abortError = new Error("aborted"); diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts index 464e3cfcf7f..1889925a275 100644 --- a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts +++ b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts @@ -13,7 +13,12 @@ export async function waitForCompactionRetryWithAggregateTimeout(params: { const timeoutMs = Number.isFinite(timeoutMsRaw) ? Math.max(1, Math.floor(timeoutMsRaw)) : 1; let timedOut = false; - const waitPromise = params.waitForCompactionRetry().then(() => "done" as const); + // Reflect the retry promise so late rejections after a timeout stay handled + // without masking failures that settle before the timeout path wins. + const waitPromise = params.waitForCompactionRetry().then( + () => ({ kind: "done" as const }), + (error: unknown) => ({ kind: "rejected" as const, error }), + ); while (true) { let timer: ReturnType | undefined; @@ -27,8 +32,11 @@ export async function waitForCompactionRetryWithAggregateTimeout(params: { ]), ); - if (result === "done") { - break; + if (result !== "timeout") { + if (result.kind === "done") { + break; + } + throw result.error; } // Keep extending the timeout window while compaction is actively running.