diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd2df44c57..e00b7ac2870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -242,6 +242,7 @@ Docs: https://docs.openclaw.ai - Matrix: keep direct transport requests on the pinned dispatcher by routing them through undici runtime fetch, so Matrix clients resume syncing on newer runtimes without dropping the validated address binding. (#61595) Thanks @gumadeiras. - Plugins/facades: resolve globally installed bundled-plugin runtime facades from registry roots so bundled channels like LINE still boot when the winning plugin install lives under the global extensions directory with an encoded scoped folder name. (#61297) Thanks @openperf. - Matrix: avoid failing startup when token auth already knows the user ID but still needs optional device metadata, retry transient auth bootstrap requests, and backfill missing device IDs after startup while keeping unknown-device storage reuse conservative until metadata is repaired. (#61383) Thanks @gumadeiras. +- Agents/exec: stop streaming `tool_execution_update` events after an exec session backgrounds, preventing delayed background output from hitting a stale listener and crashing the gateway while keeping the output available through `process poll/log`. (#61627) Thanks @openperf. ## 2026.4.2 diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index b258ca81eee..0c019d85077 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -588,6 +588,9 @@ export async function runExecProcess(opts: { if (!opts.onUpdate) { return; } + if (session.backgrounded || session.exited) { + return; + } const tailText = session.tail || session.aggregated; const warningText = opts.warnings.length ? `${opts.warnings.join("\n")}\n\n` : ""; opts.onUpdate({ diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 00c5465a76f..b617fd48800 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -679,3 +679,34 @@ describe("applyPathPrepend with case-insensitive PATH key", () => { expect("Path" in env).toBe(false); }); }); + +describe("exec backgrounded onUpdate suppression", () => { + useCapturedEnv([...SHELL_ENV_KEYS], applyDefaultShellEnv); + + it( + "does not invoke onUpdate after the session is backgrounded", + async () => { + const onUpdateSpy = vi.fn(); + const tool = createTestExecTool({ allowBackground: true, backgroundMs: 0 }); + const command = joinCommands([shellEcho("before"), yieldDelayCmd, shellEcho("after")]); + const result = await tool.execute( + nextCallId(), + { command, background: true }, + undefined, + onUpdateSpy, + ); + + expect(readProcessStatus(result.details)).toBe(PROCESS_STATUS_RUNNING); + const sessionId = requireSessionId(result.details as { sessionId?: string }); + const callsBeforeBackground = onUpdateSpy.mock.calls.length; + await expect + .poll(() => { + const finished = getFinishedSession(sessionId); + return Boolean(finished); + }, BACKGROUND_POLL_OPTIONS) + .toBe(true); + expect(onUpdateSpy.mock.calls.length).toBe(callsBeforeBackground); + }, + isWin ? 15_000 : 5_000, + ); +});