Files
multica/server/internal/daemon/opencode_clock_unix_test.go
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

152 lines
5.6 KiB
Go

//go:build !windows
package daemon
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/pkg/agent"
)
func TestRunTaskOpenCodeClockSkew(t *testing.T) {
for _, failLaunch := range []bool{false, true} {
name := "fresh session"
if failLaunch {
name = "launch failure still reports retirement"
}
t.Run(name, func(t *testing.T) {
redirectTestHome(t, t.TempDir())
// The fake CLI speaks `opencode run`; pin that transport.
t.Setenv("MULTICA_OPENCODE_TRANSPORT", "run")
dir := t.TempDir()
bin := filepath.Join(dir, "opencode")
argsPath, promptPath := filepath.Join(dir, "args"), filepath.Join(dir, "prompt")
script := `#!/bin/sh
case "$1" in
export)
printf '%s' '{"info":{"id":"ses-old","time":{"updated":9999999999999}},"messages":[]}'
if [ "$CLOCK_FAIL_LAUNCH" = true ]; then chmod -x "$0"; fi
exit 0;;
models) exit 0;;
run)
printf '%s\n' "$@" > "$CLOCK_ARGS"
cat > "$CLOCK_PROMPT"
printf '%s\n' '{"type":"step_start","sessionID":"ses-new","part":{"type":"step-start"}}'
printf '%s\n' '{"type":"text","sessionID":"ses-new","part":{"type":"text","text":"done"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"ses-new","part":{"type":"step-finish","reason":"stop"}}'
exit 0;;
esac
exit 1
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
reports := make(chan map[string]any, 2)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/fail") || strings.HasSuffix(r.URL.Path, "/complete") {
var report map[string]any
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
t.Error(err)
}
reports <- report
}
_, _ = w.Write([]byte(`{"status":"running"}`))
}))
defer srv.Close()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
d := &Daemon{
client: NewClient(srv.URL), logger: logger,
workspaces: make(map[string]*workspaceState),
runtimeIndex: map[string]Runtime{"rt-clock": {ID: "rt-clock", Provider: "opencode"}},
activeEnvRoots: make(map[string]int),
agentVersions: make(map[string]string),
cfg: Config{WorkspacesRoot: t.TempDir(), AgentTimeout: 5 * time.Second, ServerBaseURL: srv.URL, Agents: map[string]AgentEntry{"opencode": {Path: bin}}},
}
d.setAgentVersion("opencode", "1.18.31")
d.runner = taskRunnerFunc(d.runTask)
fail := "false"
if failLaunch {
fail = "true"
}
task := Task{ID: "task-clock", WorkspaceID: "ws-clock", RuntimeID: "rt-clock", IssueID: "issue-clock", AgentID: "agent-clock", AuthToken: "mat_clock", PriorSessionID: "ses-old", Agent: &AgentData{ID: "agent-clock", Name: "clock-agent", CustomEnv: map[string]string{"CLOCK_FAIL_LAUNCH": fail, "CLOCK_ARGS": argsPath, "CLOCK_PROMPT": promptPath}}}
seed := task
seed.ID, seed.PriorSessionID = "seed-clock", ""
prepared, err := d.runTask(context.Background(), seed, "opencode", 0, logger)
if err != nil || prepared.Status != "completed" {
t.Fatalf("seed managed workdir: %+v, %v", prepared, err)
}
task.PriorWorkDir = prepared.WorkDir
d.handleTask(context.Background(), task, 0)
select {
case report := <-reports:
if report["retired_session_id"] != "ses-old" {
t.Fatalf("retirement missing from terminal callback: %+v", report)
}
if report["work_dir"] == "" || report["work_dir"] == nil {
t.Fatalf("workdir lost: %+v", report)
}
if !failLaunch && report["session_id"] != "ses-new" {
t.Fatalf("fresh session not reported: %+v", report)
}
if failLaunch && report["error"] == nil {
t.Fatalf("launch failure not reported: %+v", report)
}
default:
t.Fatal("terminal callback not sent")
}
if !failLaunch {
args, err := os.ReadFile(argsPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(args), "--session") {
t.Fatalf("stale session forwarded: %s", args)
}
prompt, err := os.ReadFile(promptPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(prompt), "Session Continuity Notice") {
t.Fatalf("cold-start continuity notice missing: %s", prompt)
}
}
})
}
}
func TestOpenCodeClockSkewClassification(t *testing.T) {
reason, ok := classifyOpenCodeClockSkew("opencode", agent.ErrOpenCodeClockSkew.Error())
if !ok || reason != "opencode_clock_skew" || !service.ResumeUnsafeFailure(reason, "") {
t.Fatalf("unsafe reason not propagated: %q %v", reason, ok)
}
if _, ok := classifyOpenCodeClockSkew("codex", agent.ErrOpenCodeClockSkew.Error()); ok {
t.Fatal("other providers must not match")
}
}
func TestOpenCodeClockGateUnverifiedPreservesResume(t *testing.T) {
var logs strings.Builder
logger := slog.New(slog.NewJSONHandler(&logs, nil))
task := Task{PriorSessionID: "ses-old"}
carrier := execenv.TaskContextForEnv{PriorSessionResumed: true}
retired := gateOpenCodeResumeToSaneClock(context.Background(), &task, &carrier, "opencode", agent.Config{ExecutablePath: filepath.Join(t.TempDir(), "missing"), BuiltinRuntime: true, CLIVersion: "opencode v2.0.10"}, "", nil, logger)
if retired != "" || task.PriorSessionID != "ses-old" || !carrier.PriorSessionResumed || task.PriorSessionResumeUnavailable {
t.Fatal("unverified runtime changed resume state")
}
if !strings.Contains(logs.String(), `"resume_clock_check":"unverified"`) {
t.Fatal("missing structured degradation warning")
}
}