From 92100efa04f805329ab8c1ede6e705caff357372 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 11:06:18 +0100 Subject: [PATCH] fix(exec): honor default timeout for background runs --- CHANGELOG.md | 1 + .../bash-tools.exec.background-abort.test.ts | 39 ++++++++++++++----- src/agents/bash-tools.exec.ts | 6 +-- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 851bc0a37e0..378952b9dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Memory-core/dreaming: treat request-scoped narrative fallback as expected, skip session cleanup when no subagent run was created, and remove duplicate phase-level cleanup so fallback no longer emits warning noise. Fixes #67152. Thanks @jsompis. +- Agents/exec: apply configured `tools.exec.timeoutSec` to background and `yieldMs` commands when no per-call timeout is set, preventing auto-backgrounded commands from running indefinitely. Fixes #67600; supersedes #67603. Thanks @dlmpx and @kagura-agent. - Config/doctor: stop masking unknown-key validation diagnostics such as `agents.defaults.llm`, and have `openclaw doctor --fix` remove the retired `agents.defaults.llm` timeout block. Thanks @aidiffuser. - CLI/plugins: preserve unversioned ClawHub install specs so `plugins update` can follow newer ClawHub releases instead of pinning to the initially resolved version. Fixes #63010; supersedes #58426. Thanks @kangsen1234 and @robinspt. - Memory-core/subagents: tag plugin-created subagent sessions with their plugin owner so dreaming narrative cleanup can delete its own ephemeral sessions without granting broad admin session deletion. Fixes #72712. Thanks @BSG2000. diff --git a/src/agents/bash-tools.exec.background-abort.test.ts b/src/agents/bash-tools.exec.background-abort.test.ts index a121dd5e9ef..ea28446d104 100644 --- a/src/agents/bash-tools.exec.background-abort.test.ts +++ b/src/agents/bash-tools.exec.background-abort.test.ts @@ -177,12 +177,18 @@ async function expectBackgroundSessionTimesOut(params: { executeParams: ExecToolExecuteParams; signal?: AbortSignal; abortAfterStart?: boolean; + expectedTimeoutSec?: number; }) { const abortController = new AbortController(); const signal = params.signal ?? abortController.signal; const result = await params.tool.execute("toolcall", params.executeParams, signal); expect(result.details.status).toBe("running"); const sessionId = (result.details as { sessionId: string }).sessionId; + if (typeof params.expectedTimeoutSec === "number") { + expect(supervisorMockState.spawnInputs.at(-1)?.timeoutMs).toBe( + Math.floor(params.expectedTimeoutSec * 1000), + ); + } if (params.abortAfterStart) { abortController.abort(); @@ -223,23 +229,21 @@ test("background exec still times out after tool signal abort", async () => { timeout: BACKGROUND_TIMEOUT_SEC, }, abortAfterStart: true, + expectedTimeoutSec: BACKGROUND_TIMEOUT_SEC, }); }); -test("background exec without explicit timeout ignores default timeout", async () => { +test("background exec without explicit timeout applies default timeout", async () => { const tool = createTestExecTool({ allowBackground: true, backgroundMs: 0, timeoutSec: BACKGROUND_TIMEOUT_SEC, }); - const result = await tool.execute("toolcall", { command: BACKGROUND_HOLD_CMD, background: true }); - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - expect(supervisorMockState.spawnInputs.at(-1)?.timeoutMs).toBeUndefined(); - expect(getFinishedSession(sessionId)).toBeUndefined(); - expect(getSession(sessionId)?.exited).toBe(false); - - cleanupRunningSession(sessionId); + await expectBackgroundSessionTimesOut({ + tool, + executeParams: { command: BACKGROUND_HOLD_CMD, background: true }, + expectedTimeoutSec: BACKGROUND_TIMEOUT_SEC, + }); }); test("yielded background exec still times out", async () => { @@ -251,5 +255,22 @@ test("yielded background exec still times out", async () => { yieldMs: 5, timeout: BACKGROUND_TIMEOUT_SEC, }, + expectedTimeoutSec: BACKGROUND_TIMEOUT_SEC, + }); +}); + +test("yieldMs exec without explicit timeout applies default timeout", async () => { + const tool = createTestExecTool({ + allowBackground: true, + backgroundMs: 10, + timeoutSec: BACKGROUND_TIMEOUT_SEC, + }); + await expectBackgroundSessionTimesOut({ + tool, + executeParams: { + command: BACKGROUND_HOLD_CMD, + yieldMs: 5, + }, + expectedTimeoutSec: BACKGROUND_TIMEOUT_SEC, }); }); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 00dd918b403..196326ef1ec 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1676,11 +1676,7 @@ export function createExecTool( } const explicitTimeoutSec = typeof params.timeout === "number" ? params.timeout : null; - const backgroundTimeoutBypass = - allowBackground && explicitTimeoutSec === null && (backgroundRequested || yieldRequested); - const effectiveTimeout = backgroundTimeoutBypass - ? null - : (explicitTimeoutSec ?? defaultTimeoutSec); + const effectiveTimeout = explicitTimeoutSec ?? defaultTimeoutSec; const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : ""); const usePty = params.pty === true && !sandbox;