Repository navigation
codemode: execute and plugin tool calls have no timeout, so one slow call blocks the session until interrupted #54220
Description
Activity
opencode-agent commented
on Oct 10, 2026 on Oct 10, 2026 – with OpenCode AgentContributorMore actionsThanks for the detailed write-up. I reproduced this with opencode-drive and the simulated model. As a stand-in for your plugin, I attached a Code Mode tool
slow_wait(options.codemode: true) that never returns. The model then callsexecutewith your two-call script.- @opencode/cli 2.0.26: after 150 s the TUI still showed
execute › slow_wait [seconds=600]with a spinner and no elapsed time. No deadline fired. Pressing Esc ended the whole step asinterrupted, with both rows marked as errors. - Latest
v2from source (8eff035): same result.
The code matches your analysis.
createCodeModeinpackages/core/src/codemode/tool.tscallsCodeMode.makewithoutlimits, and the MCPcallTooldrops progress and uses the 12-hour default.The screenshots below come from the 150 s run on source. The recording is a shorter 5 s run of the same script on 2.0.26: it shows the flow but not the long wait. In the 150 s runs the drive tool got stuck after the interrupt and never saved the video.
recording.mp4
Reproduction script (drive.ts)
// Repro for #54220: a Code Mode `execute` script awaiting a slow (never-finishing) // codemode-enabled tool has no deadline; the session stays busy until interrupted. import { Effect } from "effect" import { Llm, OpenCodeDriver } from "opencode-drive" const WAIT_SECONDS = Number(process.env.WAIT_SECONDS ?? 90) export default OpenCodeDriver.use( { ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}), tui: { recording: true }, }, ({ tools, llm, ui }) => Effect.gen(function* () { // Stand-in for a slow plugin tool exposed to Code Mode (options.codemode: true). yield* tools.attach({ tools: [ { name: "slow_wait", description: "Waits for the given number of seconds.", inputSchema: { type: "object", properties: { seconds: { type: "number" } }, required: ["seconds"], }, options: { codemode: true }, }, ], }) yield* llm.queue( Llm.toolCall({ index: 0, id: "call_execute", name: "execute", input: { code: "await tools.slow_wait({ seconds: 600 })\nreturn await tools.slow_wait({ seconds: 600 })", }, }), Llm.finish("tool-calls"), ) yield* ui.submit("Run the slow tool through execute") // The nested call reaches the tool and is never settled (like a slow tool). const call = yield* tools.take().pipe(Effect.timeout("60 seconds"), Effect.option) yield* Effect.log(`nested call claimed: ${call._tag}`) yield* ui.screenshot("t0-running") yield* Effect.sleep(`${WAIT_SECONDS} seconds`) // Still running with only a spinner: no deadline, no elapsed time. yield* ui.screenshot(`t${WAIT_SECONDS}-still-running`) const frame = yield* ui.capture() yield* Effect.log("\n" + (frame as any).lines.map((l: any) => l.spans.map((x: any) => x.text).join("")).join("\n")) // Only way out: interrupt the whole step. yield* ui.press("escape") yield* ui.press("escape") yield* Effect.sleep("3 seconds") yield* ui.screenshot("after-interrupt") }), )
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.- @opencode/cli 2.0.26: after 150 s the TUI still showed
I’d build on the existing interpreter timeout, rather than add a second competing timeout around
execute. On currentv2,executeProgramalready preserves logs/tool-call records onTimeoutExceeded; the separate gap is bounding ordinary plugin invocations at the tool boundary.Proposed contract:
- Supply the configured Code Mode budget to the interpreter. Use your suggested 10-minute default as the starting point for core review, with global/per-tool overrides. Preserve completed nested-call records and logs on timeout; never automatically replay calls that may already have had side effects.
- Bound non-builtin tool execution in its own cancellation scope. Expiry becomes a
Tool.Error, so the runner’s existing per-call failure publication settles that call rather than interrupting the whole step. Keep shell/subagent/question timing semantics. Parentexecutecancellation must still interrupt its pending children. Promise plugins already expose an AbortSignal; ignoring it must not allow a late success or progress update to overwrite the timeout. - Keep an absolute execution ceiling separate from an MCP idle timeout: progress may reset the idle timer, never the ceiling. Forward progress into a nested-call slot keyed by call identity, merging with
executemetadata rather than replacing itstoolCalls. Expose elapsed time independently of progress messages. - Permission/elicitation waits need an explicit policy. I suggest displaying them as pending and excluding their duration from the active-execution budget; cancellation must remain available. This avoids silently treating a user decision as a failed tool execution.
Acceptance: timeout without ending the whole step, retained completed-child evidence, cooperative and abort-ignoring plugins, late-output suppression, deadline/completion races, nested progress, and permission waits. These are proposed checks, not tests I have run; the reproduction above is the bot’s.
Would the core team approve this scope and the deadline/permission policy?


Summary
Code Mode
executeand plugin tool calls have no time limit in V2. A script that awaits a slow plugin tool keeps the session busy, showing only a spinner, until the user interrupts it. MCP calls do have a limit, but it defaults to 12 hours.We hit this with a plugin tool that scans chat history. An
executescript awaited five calls in sequence, each taking about 3.5 minutes, and ran for 16.5 minutes before it was interrupted. Nothing was stuck; there was just nothing to bound the call or show that it was still making progress.Environment
v2at 8eff035)Reproduction
Add a local plugin with a slow tool:
Ask the agent to run this through
execute:Watch the session.
Expected Behavior
executeand non-builtin tool calls have a default deadline (for example 10 minutes) that can be configured globally and per tool. When the deadline passes, the call ends as a tool error the model can act on, the step continues, and results from nested calls that finished are kept. Long calls show elapsed time and progress.Actual Behavior
The session stays busy for 20 minutes with a spinner and no elapsed time. The only way out is to interrupt, which ends the whole step with
Tool execution interrupted.Where this comes from
executecreates its runtime withoutlimits(codemode/tool.ts#L239), sotimeoutMsis undefined. It's documented as "absent means no timeout" (codemode.ts#L23-L27). The interpreter already implements the deadline and returns aTimeoutExceededdiagnostic with logs (execute.ts#L56-L90).maxToolCallsandmaxOutputBytesare unset too.executedescription says timers are unavailable (codemode/tool.ts#L64), and a tool call accepts only one input object (tool-runtime.ts#L400-L403).timeout.executiondefaults to 12 hours (mcp/client.ts#L36).callToolpassesonprogress: () => {}withoutresetTimeoutOnProgress(mcp/client.ts#L352-L362). So progress never extends the timer, and progress notifications are dropped instead of reaching the UI.context.signalfor Promise plugin tools (adapter.ts#L624-L631), and the MCP SDK sendsnotifications/cancelled. The problem is that nothing triggers cancellation except the user ending the whole step (step.ts#L154).executecan't report its own progress. It receives theexecutecall's context, socontext.progressreplaces theexecuterow'stoolCallsmetadata (codemode/tool.ts#L95, publish-llm-event.ts#L566-L569).Proposal
limits.timeoutMstoCodeMode.make, with a configurable default (for examplecodemode.timeout, 10 minutes). Tell the model thatconsole.logoutput survives a timeout.Tool.Service.executeTool. Use a per-tool registration option first, then atools.timeoutconfig default. Exclude built-ins that manage their own time (shell,subagent,question). A timeout becomes aTool.Errorsuch as "timed out after 10m", so the step continues.resetTimeoutOnProgress: trueandmaxTotalTimeout = execution, and forwardonprogresstocontext.progress.executecall, and show elapsed time for running tools and nested rows in the TUI. Give nested calls their own progress slot instead of letting them overwrite the parent's metadata.Related: #51223 (an invisible permission ask makes
executewait forever; a deadline would bound it), #51856 (an MCP call that waits forever on elicitation), #43717 (MCP activity in the TUI), #20096 (the V1 version of this problem). #20103 and #36869 attempted V1 tool timeouts, but neither was merged into V2.