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 <vincentkoc@ieee.org>
This commit is contained in:
Kris Wu 2026-03-30 12:30:13 +08:00 committed by GitHub
parent aee61dcee0
commit 6b255b4dec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 52 additions and 3 deletions

View File

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

View File

@ -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<void>((_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");

View File

@ -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<typeof setTimeout> | 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.