6a684211fc
* 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>
218 lines
7.8 KiB
Go
218 lines
7.8 KiB
Go
// Package instance provides data structures for multi-agent instance management.
|
|
// It defines the core types used to represent, create, and serialize agent instances
|
|
// that run as separate processes with their own HTTP servers.
|
|
package instance
|
|
|
|
import (
|
|
"net/http"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
)
|
|
|
|
// Instance represents a single agent instance running as a subprocess.
|
|
// Each instance has its own process manager, HTTP server, and configuration.
|
|
type Instance struct {
|
|
// ID is the unique identifier for this instance
|
|
ID string
|
|
|
|
// Port is the HTTP port this instance is listening on
|
|
Port int
|
|
|
|
// Status is the current status of the instance (e.g., "running", "stopped", "error")
|
|
Status string
|
|
|
|
// WorkspacePath is the absolute path to the workspace directory for this instance
|
|
WorkspacePath string
|
|
|
|
// AgentCommand is the command used to start the agent subprocess
|
|
AgentCommand string
|
|
|
|
// Env contains environment variables passed to the agent process
|
|
Env map[string]string
|
|
|
|
// CreatedAt is the timestamp when this instance was created
|
|
CreatedAt time.Time
|
|
|
|
// manager is the process manager handling the agent subprocess (unexported)
|
|
manager *process.Manager
|
|
|
|
// server is the HTTP server for this instance's API (unexported)
|
|
server *http.Server
|
|
|
|
// lastActivityNanos is the unix-nano timestamp of the most recently
|
|
// observed HTTP request on this instance's port. Used by the idle reaper
|
|
// to detect disconnected sessions. Bumped on request entry and exit by
|
|
// the activity middleware installed in instance.Manager.CreateInstance.
|
|
lastActivityNanos atomic.Int64
|
|
|
|
// inflightRequests counts HTTP requests currently being served on this
|
|
// instance's port. Long-lived requests (WebSocket upgrades for
|
|
// /agent/stream, /workspace/stream, etc.) keep this above zero for the
|
|
// full duration of the connection, so the idle reaper treats an
|
|
// instance with an open WS as active even when no new HTTP request
|
|
// arrives. Maintained by the activity middleware.
|
|
inflightRequests atomic.Int32
|
|
}
|
|
|
|
// MarkActivity stamps the current time as the most recent activity on this
|
|
// instance. Called by the activity middleware on every request entry and exit.
|
|
func (i *Instance) MarkActivity() {
|
|
i.lastActivityNanos.Store(time.Now().UnixNano())
|
|
}
|
|
|
|
// LastActivity returns the timestamp of the most recent observed activity on
|
|
// this instance. Zero if no activity has been recorded yet.
|
|
func (i *Instance) LastActivity() time.Time {
|
|
v := i.lastActivityNanos.Load()
|
|
if v == 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(0, v)
|
|
}
|
|
|
|
// IsIdle reports whether the instance has been idle for at least `timeout`.
|
|
// An instance with any in-flight HTTP request (notably an open WebSocket
|
|
// stream) is never considered idle.
|
|
func (i *Instance) IsIdle(now time.Time, timeout time.Duration) bool {
|
|
if i.inflightRequests.Load() > 0 {
|
|
return false
|
|
}
|
|
last := i.LastActivity()
|
|
if last.IsZero() {
|
|
// No activity observed since creation — fall back to CreatedAt so a
|
|
// never-touched instance still gets reaped after timeout.
|
|
last = i.CreatedAt
|
|
}
|
|
if last.IsZero() {
|
|
return false
|
|
}
|
|
return now.Sub(last) >= timeout
|
|
}
|
|
|
|
// McpServerConfig holds configuration for an MCP server.
|
|
type McpServerConfig struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
}
|
|
|
|
// CreateRequest contains the parameters for creating a new agent instance.
|
|
type CreateRequest struct {
|
|
// ID is an optional identifier for the instance. If empty, one will be generated.
|
|
ID string `json:"id,omitempty"`
|
|
|
|
// WorkspacePath is the required absolute path to the workspace directory.
|
|
WorkspacePath string `json:"workspace_path"`
|
|
|
|
// AgentCommand is an optional command to start the agent. If empty, a default is used.
|
|
AgentCommand string `json:"agent_command,omitempty"`
|
|
|
|
// Protocol is the protocol adapter to use (acp, codex, auggie). If empty, default is used.
|
|
Protocol string `json:"protocol,omitempty"`
|
|
|
|
// AgentType identifies the agent (e.g., "auggie", "codex", "claude-code").
|
|
// Required for debug file naming. Typically matches the agent ID from the registry.
|
|
AgentType string `json:"agent_type,omitempty"`
|
|
|
|
// WorkspaceFlag is the CLI flag for workspace path (e.g., "--workspace-root").
|
|
// If empty, only cwd is used for workspace path.
|
|
WorkspaceFlag string `json:"workspace_flag,omitempty"`
|
|
|
|
// Env contains optional environment variables to pass to the agent process.
|
|
Env map[string]string `json:"env,omitempty"`
|
|
|
|
// AutoStart indicates whether to start the agent automatically after creation.
|
|
AutoStart bool `json:"auto_start,omitempty"`
|
|
|
|
// McpServers is a list of MCP servers to configure for the agent.
|
|
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
|
|
|
|
// SessionID is the task session ID for MCP tool calls (used by ask_user_question).
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
// TaskID is the task ID for MCP plan tool calls (server-side injection).
|
|
TaskID string `json:"task_id,omitempty"`
|
|
|
|
// DisableAskQuestion disables the ask_user_question MCP tool (for TUI agents).
|
|
DisableAskQuestion bool `json:"disable_ask_question,omitempty"`
|
|
|
|
// AssumeMcpSse overrides MCP capability filtering to assume SSE support.
|
|
AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"`
|
|
|
|
// AssumeMcpHttp overrides MCP capability filtering to assume HTTP support.
|
|
AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"`
|
|
|
|
// McpMode controls which MCP tools are registered: "task" (default) or "config".
|
|
McpMode string `json:"mcp_mode,omitempty"`
|
|
|
|
// RequiresProcessKill forces the agent's process group to be killed on
|
|
// shutdown instead of relying on stdin close. Required for agents whose
|
|
// runtime keeps child processes alive when stdin closes (notably
|
|
// opencode acp, which spawns MCP server children that leak otherwise).
|
|
RequiresProcessKill bool `json:"requires_process_kill,omitempty"`
|
|
}
|
|
|
|
// CreateResponse contains the result of creating a new agent instance.
|
|
type CreateResponse struct {
|
|
// ID is the unique identifier assigned to the created instance
|
|
ID string `json:"id"`
|
|
|
|
// Port is the HTTP port the instance is listening on
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
// InstanceInfo contains serializable information about an instance for API responses.
|
|
// It mirrors the exported fields from Instance and is safe for JSON serialization.
|
|
type InstanceInfo struct {
|
|
// ID is the unique identifier for this instance
|
|
ID string `json:"id"`
|
|
|
|
// Port is the HTTP port this instance is listening on
|
|
Port int `json:"port"`
|
|
|
|
// Status is the current status of the instance
|
|
Status string `json:"status"`
|
|
|
|
// WorkspacePath is the absolute path to the workspace directory
|
|
WorkspacePath string `json:"workspace_path"`
|
|
|
|
// AgentCommand is the command used to start the agent subprocess
|
|
AgentCommand string `json:"agent_command"`
|
|
|
|
// Env contains environment variables passed to the agent process
|
|
Env map[string]string `json:"env,omitempty"`
|
|
|
|
// CreatedAt is the timestamp when this instance was created
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Info returns a safe copy of the instance data for API serialization.
|
|
// This method creates an InstanceInfo struct containing only the exported,
|
|
// serializable fields from the Instance.
|
|
func (i *Instance) Info() *InstanceInfo {
|
|
// Create a copy of the environment map to prevent external modification
|
|
var envCopy map[string]string
|
|
if i.Env != nil {
|
|
envCopy = make(map[string]string, len(i.Env))
|
|
for k, v := range i.Env {
|
|
envCopy[k] = v
|
|
}
|
|
}
|
|
|
|
return &InstanceInfo{
|
|
ID: i.ID,
|
|
Port: i.Port,
|
|
Status: i.Status,
|
|
WorkspacePath: i.WorkspacePath,
|
|
AgentCommand: i.AgentCommand,
|
|
Env: envCopy,
|
|
CreatedAt: i.CreatedAt,
|
|
}
|
|
}
|