Files
kandev/apps/backend/internal/agent/agents/opencode_acp_test.go
T
João Salavisa 6a684211fc fix(agentctl): reap disconnected ACP sessions + MCP child trees (#1249)
* fix(agentctl): reap disconnected ACP sessions + MCP child trees (#1247)

Two independent leaks let opencode acp sessions accumulate indefinitely:

1. instance.Manager never reaped disconnected sessions. StopInstance
   only ran via the kandev DELETE route, so any session whose backend
   went away (UI closed mid-run, WS dropped, opencode failed to emit
   complete) was orphaned forever.
2. Even when DELETE fired, the ACP adapter unconditionally returned
   RequiresProcessKill=false. opencode acp keeps an HTTP server + MCP
   child tree alive after stdin EOF, so closing stdin was a no-op and
   the children re-parented to init.

Fix both:

- Plumb RuntimeConfig.RequiresProcessKill from agents.OpenCodeACP
  through the ExecutorCreateRequest -> CreateInstanceRequest ->
  InstanceConfig -> adapter.Config -> shared.Config chain. acp.Adapter
  now reads cfg.RequiresProcessKill instead of hardcoding false.
- Belt-and-braces: process.Manager.waitForProcessExit timeout fallback
  now killProcessGroup first (only falls back to single-process kill
  if that errors), so any future ACP CLI that ignores stdin EOF still
  gets reaped.
- Add an idle-instance reaper to instance.Manager. Per-instance
  activity middleware tracks inflight HTTP requests + lastActivity;
  a background goroutine sweeps every minute and StopInstance's any
  instance idle past KANDEV_ACP_IDLE_TIMEOUT (default 1h, set to 0
  to disable). Long-lived /agent/stream WS connections keep the
  inflight counter above zero so active sessions are never reaped.
- Validate stdio MCP commands at CreateInstance time via
  exec.LookPath; missing entries are dropped with a warn instead of
  spawning a permanently broken child every session (fixes the
  stale /snap/bin/brave MCP repro in the issue).

Regression tests: process-group kill (parent+child fixture), idle
reaper end-to-end, MCP validation, opencode-acp opt-in vs other
ACP agents' default.

* fix: address PR review feedback on #1247 fix

- idle_reaper: extract reapSingleIdleInstance helper with defer cancel(),
  add reaperStop polling between sweep iterations so Shutdown drains in
  one StopInstance window instead of N * timeout (cubic, claude, greptile).
- instance.Manager: use sync.Once instead of m.mu to gate reaper-stop
  channel close (claude).
- mcpStdioValidationError: tolerate compound Command strings by
  resolving only the first whitespace-delimited token; reject entries
  with neither URL nor Command outright; document agentctl-vs-agent
  PATH skew in Docker/SSH executor modes (claude, greptile, cubic).
- manager_killgroup_test: treat zombie state as dead via /proc/<pid>/stat
  on Linux + bump Eventually window to 15s — CI containers' PID 1 can
  delay harvesting orphans long enough to flake signal(0) probes.
- instance package: add goleak TestMain for the new reaper goroutine
  (coderabbit).
- AGENTS.md: correct SIGKILL vs SIGTERM wording; clarify the timeout
  fallback only fires on ctx.Done (coderabbit, cubic).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-02 21:46:49 +01:00

74 lines
2.3 KiB
Go

package agents
import (
"slices"
"testing"
)
// TestOpenCodeACPRuntime_RequiresProcessKill is the regression test for GH
// issue #1247: opencode acp keeps its HTTP server + MCP child tree alive
// when stdin closes, so its RuntimeConfig must signal that the process
// group has to be killed on shutdown. Without this flag the ACP adapter
// returns RequiresProcessKill=false and the process manager only closes
// stdin — opencode never exits and MCP children leak.
func TestOpenCodeACPRuntime_RequiresProcessKill(t *testing.T) {
rt := NewOpenCodeACP().Runtime()
if rt == nil {
t.Fatal("Runtime() returned nil")
}
if !rt.RequiresProcessKill {
t.Error("RequiresProcessKill = false; opencode acp must opt into process-group kill")
}
}
// TestACPAgents_DefaultProcessKill confirms the rest of the ACP agents
// stick with the default (false). They communicate over plain stdin/stdout
// and exit on EOF, so the process manager's graceful-stdin-close path is
// the correct shutdown signal — flipping this true would force-kill agents
// that are perfectly capable of cleanup.
func TestACPAgents_DefaultProcessKill(t *testing.T) {
cases := []struct {
name string
agent Agent
}{
{"claude", NewClaudeACP()},
{"codex", NewCodexACP()},
{"cursor", NewCursorACP()},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := tc.agent.Runtime()
if rt == nil {
t.Fatalf("%s Runtime() returned nil", tc.name)
}
if rt.RequiresProcessKill {
t.Errorf("%s RequiresProcessKill = true; expected default false", tc.name)
}
})
}
}
func TestOpenCodeACPRemoteAuth(t *testing.T) {
auth := NewOpenCodeACP().RemoteAuth()
if auth == nil {
t.Fatal("RemoteAuth() returned nil; expected files-based auth method")
}
if len(auth.Methods) != 1 {
t.Fatalf("Methods len = %d, want 1", len(auth.Methods))
}
m := auth.Methods[0]
if m.Type != "files" {
t.Errorf("Type = %q, want %q", m.Type, "files")
}
if m.TargetRelDir != ".local/share/opencode" {
t.Errorf("TargetRelDir = %q, want %q", m.TargetRelDir, ".local/share/opencode")
}
want := []string{".local/share/opencode/auth.json"}
for _, os := range []string{"darwin", "linux"} {
got := m.SourceFiles[os]
if !slices.Equal(got, want) {
t.Errorf("SourceFiles[%q] = %v, want %v", os, got, want)
}
}
}