From b96ac7105d6ef50e67fa74600c8ea50508150346 Mon Sep 17 00:00:00 2001 From: Super Zheng Date: Fri, 8 May 2026 15:12:42 +0800 Subject: [PATCH] perf(agents): skip idle wait on abort to release session lock synchronously (#74919) Merged via squash. Prepared head SHA: 0af4c4685f6a6374247e8ccf77a3afc154023251 Co-authored-by: medns <1575008+medns@users.noreply.github.com> Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com> Reviewed-by: @odysseus0 --- CHANGELOG.md | 1 + ...ner.guard.waitforidle-before-flush.test.ts | 39 +++++++++++++++++++ .../run/attempt.subscription-cleanup.ts | 5 +++ src/agents/pi-embedded-runner/run/attempt.ts | 12 ++++++ .../wait-for-idle-before-flush.ts | 12 ++++-- 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32093d2f1d2..2a7453d389e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -625,6 +625,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: stop Gateway-originated outbound echoes from advancing inbound activity in `openclaw channels status`, so outbound self-sends no longer look like handled inbound messages. Fixes #79056. (#79057) Thanks @ai-hpc and @bittoby. - Gateway/nodes: preserve the live node registry session and invoke ownership when an older same-node WebSocket closes after reconnecting. (#78351) Thanks @samzong. - Browser/downloads: route explicit and managed browser download output directories through `fs-safe` validation before staging final files, so symlinked output roots are rejected before writes. (#78780) Thanks @jesse-merhi. +- Agents/PI: skip the idle wait during aborted embedded-run cleanup, so stopped or timed-out runs clear pending tool state and release the session lock promptly. (#74919) Thanks @medns. ## 2026.5.3-1 diff --git a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts index 207e721ac81..3cb2e8c79aa 100644 --- a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts +++ b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts @@ -138,4 +138,43 @@ describe("flushPendingToolResultsAfterIdle", () => { }); expect(vi.getTimerCount()).toBe(0); }); + + it("immediately clears pending tool results without waiting when timeoutMs is 0 or less", async () => { + const sm = guardSessionManager(SessionManager.inMemory()); + const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void; + + // Agent that never resolves idle + const idle = deferred(); + const waitForIdleSpy = vi.fn(() => idle.promise); + const agent = { waitForIdle: waitForIdleSpy }; + + appendMessage(assistantToolCall("call_orphan_immediate")); + + // Should resolve immediately without advancing timers + await flushPendingToolResultsAfterIdle({ + agent, + sessionManager: sm, + timeoutMs: 0, + clearPendingOnTimeout: true, + }); + + // Verify waitForIdle was completely bypassed + expect(waitForIdleSpy).not.toHaveBeenCalled(); + + // The pending tool result should be cleared immediately. + expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant"]); + + // Test negative timeout as well + appendMessage(assistantToolCall("call_orphan_negative")); + await flushPendingToolResultsAfterIdle({ + agent, + sessionManager: sm, + timeoutMs: -100, + clearPendingOnTimeout: true, + }); + + // Verify waitForIdle was still bypassed + expect(waitForIdleSpy).not.toHaveBeenCalled(); + expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant", "assistant"]); + }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts index 5c11eec1412..c23574d382b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts @@ -30,6 +30,7 @@ export async function cleanupEmbeddedAttemptResources(params: { bundleMcpRuntime?: { dispose(): Promise | void }; bundleLspRuntime?: { dispose(): Promise | void }; sessionLock: { release(): Promise | void }; + aborted?: boolean; }): Promise { try { try { @@ -37,11 +38,15 @@ export async function cleanupEmbeddedAttemptResources(params: { } catch { /* best-effort */ } + // PERF: When the run was aborted (user stop / timeout), skip the expensive + // waitForIdle (up to 30 s) and just clear pending tool results synchronously + // so the session write-lock is released ASAP and the next message is not blocked. try { await params.flushPendingToolResultsAfterIdle({ agent: params.session?.agent as IdleAwareAgent | null | undefined, sessionManager: params.sessionManager as ToolResultFlushManager | null | undefined, clearPendingOnTimeout: true, + ...(params.aborted ? { timeoutMs: 0 } : {}), }); } catch { /* best-effort */ diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 682b91ecd04..9248cacdbe7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -2345,6 +2345,10 @@ export async function runEmbeddedAttempt( agent: activeSession?.agent, sessionManager, clearPendingOnTimeout: true, + // PERF: If the run was aborted during the setup, + // skip the idle wait and clear pending results synchronously so we can + // immediately dispose the session and throw the error without blocking. + ...(params.abortSignal?.aborted ? { timeoutMs: 0 } : {}), }); activeSession.dispose(); throw err; @@ -3845,6 +3849,14 @@ export async function runEmbeddedAttempt( bundleMcpRuntime, bundleLspRuntime, sessionLock, + // PERF: If the run was aborted (user stop, timeout, etc.), skip the idle wait + // and clear pending results synchronously so we can release the session lock ASAP. + aborted: + Boolean(params.abortSignal?.aborted) || + aborted || + timedOut || + idleTimedOut || + timedOutDuringCompaction, }); } catch (err) { cleanupError = err; diff --git a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts b/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts index e38089b8789..6ccd0869920 100644 --- a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts +++ b/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts @@ -46,10 +46,14 @@ export async function flushPendingToolResultsAfterIdle(opts: { timeoutMs?: number; clearPendingOnTimeout?: boolean; }): Promise { - const timedOut = await waitForAgentIdleBestEffort( - opts.agent, - opts.timeoutMs ?? DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS, - ); + const isImmediateTimeout = opts.timeoutMs !== undefined && opts.timeoutMs <= 0; + const timedOut = + isImmediateTimeout || + (await waitForAgentIdleBestEffort( + opts.agent, + opts.timeoutMs ?? DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS, + )); + if (timedOut && opts.clearPendingOnTimeout && opts.sessionManager?.clearPendingToolResults) { opts.sessionManager.clearPendingToolResults(); return;