fix: stop emitting post-background exec updates (#61627) (thanks @openperf)

* fix(exec ): stop emitting tool updates after session is backgrounded

When an exec session is backgrounded (background: true), the owning
agent run resolves its tool-call promise and may finish.  The stdout
handler's emitUpdate() closure, however, kept invoking opts.onUpdate(),
delivering tool_execution_update events to a listener whose active run
had already ended.  This surfaced as an unhandled rejection and crashed
the gateway process.

Guard emitUpdate() with a session.backgrounded || session.exited check
so that post-background output is still captured via appendOutput() but
no longer forwarded to the (now-stale) agent-loop callback.

Fixes #61592

* style: trim exec backgrounding comments

* fix: stop emitting post-background exec updates (#61627) (thanks @openperf)

* fix: place exec changelog entry at end of fixes (#61627) (thanks @openperf)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Chunyue Wang
2026-04-06 12:17:30 +08:00
committed by GitHub
parent e6d6b10470
commit b682202016
3 changed files with 35 additions and 0 deletions

View File

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

View File

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

View File

@@ -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,
);
});