Files
7d02596fc3 fix(agent): MUL-8307 drive OpenCode over ACP so long tools and subagents are not force-stopped (#9619)
* fix(agent): drive OpenCode over ACP so long tools and subagents are not force-stopped

`opencode run --format json` reports a tool only when it finishes, with the
call and its result in one event. The daemon never saw a tool in flight, so a
long silent tool or a delegated subagent was judged by the 10-minute OpenCode
idle window instead of the tool budget and force-stopped (GH #9614).

Built-in OpenCode 1.15+ and 2.x runtimes now run `opencode acp`, which on both
majors announces a tool when it starts executing. MessageToolUse is emitted
from that in_progress frame, so the daemon applies AgentToolWatchdog for the
whole tool. One code path covers both majors:

- model via session/set_config_option (2.x has no session/set_model); effort
  via the advertised `effort` option, splitting 2.x `model#variant`
- resume via session/load; a missing session is reported as ResumeRejected
- cancel via session/cancel, which aborts the running tool on both majors
- MCP: 1.x keeps OPENCODE_CONFIG_CONTENT; 2.x gets servers in session/new,
  which lifts the 2.x MCP refusal on this transport
- 2.x subagent text (`_meta["opencode/child-session"]`) stays out of the
  parent's output; its tool calls are kept
- usage summed from the session export over this turn's assistant messages,
  since the prompt result's usage is not per-turn on either major

The run transport stays as the fallback for custom profiles, older or
unknown versions, agents with custom_args, and MULTICA_OPENCODE_TRANSPORT=run.

Also fix the run transport on 2.x: tool_use parts carry the call id as `id`
(1.x: `callID`), so every 2.x tool message had an empty CallID.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): bound OpenCode ACP teardown when the runtime stops reading

A runtime that stops reading stdin mid-prompt left the prompt write blocked
holding the client's write lock. The cancel path then waited on that lock to
send session/cancel, and the cancel grace and the process-group kill only ran
after the write, so the process could outlive the task indefinitely.

- session/cancel is sent only once the prompt is fully delivered, from its own
  goroutine, so the grace starts immediately and a stalled pipe cannot hold it
- a turn OpenCode never answered (or a cancelled/timed-out setup) is torn
  down forcibly: stdin closed first to release a blocked write, then SIGTERM to
  the group and SIGKILL after the grace; an answered turn still exits on EOF

Regression tests cover a stalled prompt write under a 1s timeout and a runtime
that ignores session/cancel.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): bound OpenCode ACP teardown when a setup write stalls

Setup requests (initialize, session/new, session/load, set_config_option)
are synchronous stdin writes that check their context only after the write
returns. A runtime that stops reading after initialize, with a large MCP
payload in session/new, left the run goroutine blocked in that write, so
the deferred teardown never ran and the process outlived the timeout.

A watcher now force-stops the process as soon as the run ends before the
prompt is fully delivered; closing stdin releases the blocked write. Once
the prompt is delivered the existing session/cancel path owns shutdown.

Regression test: 256 KiB MCP env, runtime stalls after initialize, 1s
timeout; returns in ~1s (60s before the fix).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-28 15:11:27 +08:00

294 lines
13 KiB
Go

package agent
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"time"
)
// OpenCode 2.0 reorganised the contract `opencode run` speaks. Four things the
// daemon depends on changed at once, and only the first announces itself:
//
// - `--dir` was removed. The task workdir now comes from the process cwd,
// which the backend already sets (together with PWD).
// - `--variant` was removed. A model variant now rides along inside the model
// string as `provider/model#variant`.
// - OPENCODE_CONFIG_CONTENT is no longer honoured, and the only channel left
// for MCP puts credentials somewhere the agent can commit them. Such runs
// are refused — see ErrOpenCodeV2MCPUnsupported.
// - `opencode run` became a thin client in front of a resident background
// service, so signalling the client's process group no longer stops the
// work that run started.
//
// The two removed flags fail loudly — `Unrecognized flag: --dir`, exit 1 within
// ~130ms (GH #8586), and the same for `--variant` on any agent with a thinking
// level. The other two do not announce themselves at all: before this file, a
// run that merely dropped `--dir` started fine and then executed without its MCP
// servers and without a working cancel. Handling them together is what keeps
// fixing the loud failure from exposing the quiet ones.
//
// Everything else the backend relies on was checked against 2.0.10 and is
// unchanged across the two majors — the `--format json` event vocabulary,
// `--session` resume, `.opencode/skills/` discovery, AGENTS.md, and
// `--dangerously-skip-permissions` (still accepted and still honoured) — so
// none of it is branched on here.
// opencodeInterruptTimeout bounds the session-interrupt call made while a run
// is being cancelled. It is deliberately short: the interrupt is an extra step
// in front of the termination path that already works, so a service that does
// not answer promptly must not delay the signals behind it.
const opencodeInterruptTimeout = 5 * time.Second
// opencodeUsesV2Contract reports whether the resolved OpenCode CLI speaks the
// 2.x contract described above.
//
// Scoped to BuiltinRuntime for the same reason opencodeSeparatesReasoning is: a
// custom runtime profile wraps a binary this package did not choose, and its
// reported version string does not establish which usage convention that binary
// speaks. Reading "2.x" off a wrapper that actually execs OpenCode 1.x would
// break a runtime that works today, so anything unrecognised keeps the 1.x
// argv. A custom profile pointed at OpenCode 2.x therefore stays broken until
// someone can map profiles onto capabilities directly — which is the same
// trade-off the reasoning check already makes, and strictly better than
// regressing working 1.x profiles on a guess.
func opencodeUsesV2Contract(cfg Config) bool {
if !cfg.BuiltinRuntime {
return false
}
// parseSemver scans for a semver token anywhere in the string, which is what
// this needs: 2.x reports `opencode v2.0.10` where 1.x reports a bare
// `1.18.31`, and extractVersionLine keeps whichever whole line it matched.
version, err := parseSemver(strings.TrimSpace(cfg.CLIVersion))
if err != nil {
return false
}
return version.Major >= 2
}
// opencodeModelArg folds a thinking level into the model string for the 2.x
// contract, which has no `--variant` flag and instead reads the variant off the
// model as `provider/model#variant`.
//
// The second return reports whether the thinking level was representable. It is
// false only when there is a level but no model to attach it to: 2.x resolves
// the default model server-side, so there is no model string to append to and
// the level cannot be expressed at all. Callers warn rather than fail, because
// losing the reasoning effort is not worth failing an otherwise valid task.
func opencodeModelArg(model, thinkingLevel string) (string, bool) {
if thinkingLevel == "" {
return model, true
}
if model == "" {
return model, false
}
// A model that already carries a variant was pinned deliberately (by
// agent.model or custom_args); it wins over the agent-level thinking level
// rather than growing a second `#` that OpenCode would reject.
if strings.Contains(model, "#") {
return model, true
}
return model + "#" + thinkingLevel, true
}
// ErrOpenCodeV2MCPUnsupported reports that the MCP servers Multica manages for
// a task cannot be delivered to an OpenCode 2.x runtime yet.
//
// This applies to the `opencode run` transport only. The ACP transport, which
// built-in 2.x runtimes use by default (opencode_acp.go), passes MCP servers in
// ACP session/new and never needs the workdir file.
//
// The message deliberately does not name agent.mcp_config. ExecOptions.McpConfig
// is the *composed* set, and only one of its sources is the agent's own column:
// the daemon also folds in the workspace MCP servers bound to the agent (at
// claim), the task's integration/Composio servers and the plugin-hook tool
// server (at run), and the runtime's own servers on top of those. A task whose
// agent has no MCP configuration at all still arrives here with a non-empty
// config, so an error telling the operator to edit agent.mcp_config would point
// at a field that may be empty already and would not release the task.
//
// 2.x honours no environment channel for config — OPENCODE_CONFIG_CONTENT (the
// 1.x channel), OPENCODE_CLI_CONFIG_CONTENT, OPENCODE_CONFIG and
// OPENCODE_CONFIG_DIR were each checked against 2.0.10, with and without
// --standalone, and none of them reach the session. The only channel that works
// is <workdir>/opencode.json, and MCP entries carry bearer headers, OAuth client
// secrets and environment values.
//
// Writing them there is not safe today. The workdir is the agent's own working
// tree, and in local-directory mode it is the user's checkout: the agent can
// commit that file mid-run with an ordinary `git add -A`, which puts the
// credential in Git history where no end-of-task cleanup can reach it. File
// permissions do not help, because the committing process is the same user.
//
// Failing here is deliberate. Running the task anyway would silently drop every
// MCP server the agent was configured with, which is harder to diagnose than a
// refusal naming the cause.
//
// The way out is known and measured, and wants its own change (MUL-7523):
// `--standalone` gives the run a private server that inherits the daemon's
// environment, and 2.x then resolves `{env:NAME}` placeholders inside the
// config, so the file can carry references while the secrets stay in the
// process environment. That costs a server start per run and changes the
// process topology cancellation depends on, so it is not something to fold into
// a compatibility fix.
var ErrOpenCodeV2MCPUnsupported = errors.New(
"opencode: Multica-managed MCP servers cannot be delivered to an OpenCode 2.x runtime yet. " +
"2.x accepts MCP configuration only through a file in the task working directory, where " +
"the agent's own commits would capture the servers' credentials. " +
"To run this task: point the agent at an OpenCode 1.x runtime, or remove the MCP servers " +
"Multica supplies it — these can come from the agent's MCP configuration, workspace MCP " +
"servers bound to the agent, the workspace's integration tools, or an installed plugin's " +
"hook tools")
// opencodeCheckMCPSupport fails a 2.x run that carries MCP configuration, rather
// than starting it without the servers it was supposed to get.
func opencodeCheckMCPSupport(raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
// An mcp_config that translates to no servers asks for nothing, so there is
// nothing to refuse.
servers, err := translateMCPConfigForOpenCode(raw)
if err != nil {
return err
}
if len(servers) == 0 {
return nil
}
return ErrOpenCodeV2MCPUnsupported
}
// opencodeSessionTracker carries the session id observed on a run's event
// stream over to that run's cancellation handler, which needs it to interrupt
// the session server-side.
//
// It exists because the two live in different goroutines: the scanner learns the
// id from the first event that carries one, while the cancellation handler is
// parked on the context. Execute gives every run its own tracker, so two runs
// sharing a Backend value cannot see each other's session.
type opencodeSessionTracker struct {
mu sync.Mutex
id string
}
// set records the first session id seen. Safe on a nil tracker so backends built
// without one (every unit test that drives processEvents directly) need no
// special casing.
func (t *opencodeSessionTracker) set(id string) {
if t == nil || id == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.id == "" {
t.id = id
}
}
// get returns the observed session id, or "" if no event carried one yet.
func (t *opencodeSessionTracker) get() string {
if t == nil {
return ""
}
t.mu.Lock()
defer t.mu.Unlock()
return t.id
}
// opencodeRunConnection is the connection context a run was launched with, so
// its interrupt reaches the same service the work is actually running on.
//
// `opencode run` accepts `--server <url>` (and users can pass one through
// agent.custom_args), and it resolves the default background service relative to
// the process environment and working directory. An interrupt that dropped any
// of that would report success against a different service while the real
// session kept running.
type opencodeRunConnection struct {
cmd Command
// server is the `--server` value the run was launched with, if any.
server string
// standalone reports that the run owns a private server. That server is a
// child of the client, so the existing process-group signalling already
// stops it and no interrupt is needed.
standalone bool
// env and dir mirror the run's process environment and working directory,
// which is how the CLI discovers the default service.
env []string
dir string
}
// opencodeConnectionFromArgs reads the connection flags out of a run's final
// argv, after custom_args have been merged in.
func opencodeConnectionFromArgs(args []string) (server string, standalone bool) {
for i, arg := range args {
switch {
case arg == "--standalone":
standalone = true
case arg == "--server" && i+1 < len(args):
server = args[i+1]
case strings.HasPrefix(arg, "--server="):
server = strings.TrimPrefix(arg, "--server=")
}
}
return server, standalone
}
// opencodeInterruptSession asks the OpenCode 2.x service to stop a session. On
// 2.x this is the only thing that actually stops a run.
//
// `opencode run` is a thin client: the work happens inside a background service
// that outlives it, so the SIGTERM→SIGKILL the backend sends to the client's
// process group leaves the agent running — still calling tools, still writing to
// the workdir — after Multica has already recorded the task as finished.
// Reproduced against 2.0.10: with the client confirmed dead, a shell command the
// agent had started went on to complete 35 seconds later.
//
// Best effort by construction. This runs in front of the existing termination
// path and never replaces it, so an unreachable service, a session id that was
// never observed, or a non-zero exit all degrade to exactly the behaviour
// without it. It deliberately does not use the run's context: by the time this
// is called that context is already cancelled, which is what triggered it.
func opencodeInterruptSession(conn opencodeRunConnection, sessionID string, logger *slog.Logger) {
if sessionID == "" {
return
}
if conn.standalone {
// A private server dies with the process group below; interrupting the
// default service here would signal an unrelated session.
return
}
ctx, cancel := context.WithTimeout(context.Background(), opencodeInterruptTimeout)
defer cancel()
args := []string{"api", "POST", "/api/session/" + sessionID + "/interrupt"}
if conn.server != "" {
args = append(args, "--server", conn.server)
}
cmd := conn.cmd.exec(ctx, args...)
hideAgentWindow(cmd)
// Same environment and working directory as the run, because that is what
// the CLI uses to find the default background service.
cmd.Env = conn.env
cmd.Dir = conn.dir
// combinedOutputOwned rather than cmd.CombinedOutput: the context timeout
// alone does not bound this call. CombinedOutput waits for EOF on the output
// pipes, and an `api` process that exits while a descendant still holds them
// keeps the read blocked — with the termination path behind it stuck too.
// runOwned puts the call in its own process tree, applies a WaitDelay to the
// pipe wait, and kills whatever is left.
out, err := combinedOutputOwned(cmd, logger)
if err != nil {
if logger != nil {
logger.Warn("opencode: session interrupt failed; the agent may keep running server-side",
"session", sessionID, "server", conn.server, "error", err, "output", strings.TrimSpace(string(out)))
}
return
}
if logger != nil {
logger.Info("opencode: session interrupted", "session", sessionID, "server", conn.server)
}
}