perf(agents): skip idle wait on abort to release session lock synchronously (#74919)

Merged via squash.

Prepared head SHA: 0af4c4685f
Co-authored-by: medns <1575008+medns@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
This commit is contained in:
Super Zheng
2026-05-08 15:12:42 +08:00
committed by GitHub
parent a92a349925
commit b96ac7105d
5 changed files with 65 additions and 4 deletions

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ export async function cleanupEmbeddedAttemptResources(params: {
bundleMcpRuntime?: { dispose(): Promise<void> | void };
bundleLspRuntime?: { dispose(): Promise<void> | void };
sessionLock: { release(): Promise<void> | void };
aborted?: boolean;
}): Promise<void> {
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 */

View File

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

View File

@@ -46,10 +46,14 @@ export async function flushPendingToolResultsAfterIdle(opts: {
timeoutMs?: number;
clearPendingOnTimeout?: boolean;
}): Promise<void> {
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;