Skip to content

codemode: execute and plugin tool calls have no timeout, so one slow call blocks the session until interrupted #54220

Description

@kitlangton

Summary

Code Mode execute and 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 execute script 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

  • opencode version: 0.0.0-dev-20788 (V2, v2 at 8eff035)
  • OS: macOS (Darwin 27.0.0, arm64)
  • Install/channel: dev
  • Active plugins: a local Promise plugin that provides the slow tool (minimal version below)

Reproduction

  1. Add a local plugin with a slow tool:

    import { Plugin } from "@opencode/plugin"
    
    export default Plugin.define({
      id: "slow-tool",
      async setup(ctx) {
        await ctx.tool.transform((editor) => {
          editor.add({
            name: "slow_wait",
            description: "Waits for the given number of seconds.",
            input: { type: "object", properties: { seconds: { type: "number" } }, required: ["seconds"] },
            options: { codemode: true },
            execute: async ({ seconds }) => {
              await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
              return { content: `waited ${seconds}s` }
            },
          })
        })
      },
    })
  2. Ask the agent to run this through execute:

    await tools.slow_wait({ seconds: 600 })
    return await tools.slow_wait({ seconds: 600 })
  3. Watch the session.

Expected Behavior

execute and 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

  • execute creates its runtime without limits (codemode/tool.ts#L239), so timeoutMs is undefined. It's documented as "absent means no timeout" (codemode.ts#L23-L27). The interpreter already implements the deadline and returns a TimeoutExceeded diagnostic with logs (execute.ts#L56-L90). maxToolCalls and maxOutputBytes are unset too.
  • Scripts can't set their own deadline, because the execute description says timers are unavailable (codemode/tool.ts#L64), and a tool call accepts only one input object (tool-runtime.ts#L400-L403).
  • The tool service runs tools with no deadline (tool.ts#L113-L157, tool/runtime.ts#L29-L61). Only some built-ins bound themselves (shell, webfetch, grep, websearch).
  • MCP: timeout.execution defaults to 12 hours (mcp/client.ts#L36). callTool passes onprogress: () => {} without resetTimeoutOnProgress (mcp/client.ts#L352-L362). So progress never extends the timer, and progress notifications are dropped instead of reaching the UI.
  • Cancellation itself works. Interrupting aborts context.signal for Promise plugin tools (adapter.ts#L624-L631), and the MCP SDK sends notifications/cancelled. The problem is that nothing triggers cancellation except the user ending the whole step (step.ts#L154).
  • A plugin tool called from execute can't report its own progress. It receives the execute call's context, so context.progress replaces the execute row's toolCalls metadata (codemode/tool.ts#L95, publish-llm-event.ts#L566-L569).

Proposal

  1. Pass limits.timeoutMs to CodeMode.make, with a configurable default (for example codemode.timeout, 10 minutes). Tell the model that console.log output survives a timeout.
  2. Add a generic deadline in Tool.Service.executeTool. Use a per-tool registration option first, then a tools.timeout config default. Exclude built-ins that manage their own time (shell, subagent, question). A timeout becomes a Tool.Error such as "timed out after 10m", so the step continues.
  3. For MCP, lower the 12 hour default to the same value. Add an idle timeout with resetTimeoutOnProgress: true and maxTotalTimeout = execution, and forward onprogress to context.progress.
  4. Store a start and end time for each nested execute call, 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 execute wait 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.

Activity

  1. opencode-agent commented on Oct 10, 2026

    @opencode-agent
    Contributor

    Thanks 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 calls execute with 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 as interrupted, with both rows marked as errors.
    • Latest v2 from source (8eff035): same result.

    The code matches your analysis. createCodeMode in packages/core/src/codemode/tool.ts calls CodeMode.make without limits, and the MCP callTool drops 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

    Screenshot

    Screenshot

    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 drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. Dante-dan commented on Oct 10, 2026

    @Dante-dan

    I’d build on the existing interpreter timeout, rather than add a second competing timeout around execute. On current v2, executeProgram already preserves logs/tool-call records on TimeoutExceeded; the separate gap is bounding ordinary plugin invocations at the tool boundary.

    Proposed contract:

    1. 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.
    2. 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. Parent execute cancellation 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.
    3. 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 execute metadata rather than replacing its toolCalls. Expose elapsed time independently of progress messages.
    4. 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions