mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>
152 lines
5.6 KiB
Go
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")
|
|
}
|
|
}
|