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>
205 lines
6.3 KiB
Go
205 lines
6.3 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/executor"
|
|
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// StandaloneExecutor implements Runtime for standalone agentctl execution.
|
|
// In this mode, a single agentctl control server manages multiple agent instances.
|
|
type StandaloneExecutor struct {
|
|
ctl *agentctl.ControlClient
|
|
host string
|
|
port int
|
|
authToken string // per-launch auth token from launcher
|
|
logger *logger.Logger
|
|
interactiveRunner *process.InteractiveRunner
|
|
}
|
|
|
|
// NewStandaloneExecutor creates a new standalone runtime.
|
|
func NewStandaloneExecutor(ctl *agentctl.ControlClient, host string, port int, log *logger.Logger) *StandaloneExecutor {
|
|
return &StandaloneExecutor{
|
|
ctl: ctl,
|
|
host: host,
|
|
port: port,
|
|
logger: log.WithFields(zap.String("runtime", "standalone")),
|
|
}
|
|
}
|
|
|
|
// SetAuthToken sets the per-launch auth token for authenticating instance clients.
|
|
func (r *StandaloneExecutor) SetAuthToken(token string) {
|
|
r.authToken = token
|
|
}
|
|
|
|
func (r *StandaloneExecutor) Name() executor.Name {
|
|
return executor.NameStandalone
|
|
}
|
|
|
|
func (r *StandaloneExecutor) HealthCheck(ctx context.Context) error {
|
|
return r.ctl.Health(ctx)
|
|
}
|
|
|
|
func (r *StandaloneExecutor) waitForReady(ctx context.Context) error {
|
|
if err := r.ctl.Health(ctx); err == nil {
|
|
return nil
|
|
}
|
|
|
|
waitCtx := ctx
|
|
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
|
var cancel context.CancelFunc
|
|
waitCtx, cancel = context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
}
|
|
|
|
ticker := time.NewTicker(500 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-waitCtx.Done():
|
|
return fmt.Errorf("agentctl not ready: %w", waitCtx.Err())
|
|
case <-ticker.C:
|
|
if err := r.ctl.Health(waitCtx); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
|
|
if err := r.waitForReady(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build environment variables
|
|
env := req.Env
|
|
if env == nil {
|
|
env = make(map[string]string)
|
|
}
|
|
env["KANDEV_TASK_ID"] = req.TaskID
|
|
env["KANDEV_SESSION_ID"] = req.SessionID
|
|
|
|
// Create instance via control API
|
|
// Agent command is NOT set - workspace access only. Agent is started explicitly via agentctl client.
|
|
agentType := ""
|
|
if req.AgentConfig != nil {
|
|
agentType = req.AgentConfig.ID()
|
|
}
|
|
disableAskQuestion := agents.IsPassthroughOnly(req.AgentConfig)
|
|
assumeMcpSse := false
|
|
assumeMcpHttp := false
|
|
requiresProcessKill := false
|
|
if req.AgentConfig != nil {
|
|
if rt := req.AgentConfig.Runtime(); rt != nil {
|
|
assumeMcpSse = rt.AssumeMcpSse
|
|
assumeMcpHttp = rt.AssumeMcpHttp
|
|
requiresProcessKill = rt.RequiresProcessKill
|
|
}
|
|
}
|
|
|
|
createReq := &agentctl.CreateInstanceRequest{
|
|
ID: req.InstanceID,
|
|
WorkspacePath: req.WorkspacePath,
|
|
AgentCommand: "", // Agent command set via Configure endpoint
|
|
Protocol: req.Protocol,
|
|
AgentType: agentType,
|
|
Env: env,
|
|
AutoStart: false,
|
|
McpServers: req.McpServers,
|
|
SessionID: req.SessionID,
|
|
TaskID: req.TaskID,
|
|
DisableAskQuestion: disableAskQuestion,
|
|
AssumeMcpSse: assumeMcpSse,
|
|
AssumeMcpHttp: assumeMcpHttp,
|
|
McpMode: req.McpMode,
|
|
RequiresProcessKill: requiresProcessKill,
|
|
}
|
|
|
|
r.logger.Info("CreateInstance: sending request to agentctl",
|
|
zap.String("instance_id", req.InstanceID),
|
|
zap.String("req_protocol", req.Protocol),
|
|
zap.String("createReq_protocol", createReq.Protocol))
|
|
|
|
resp, err := r.ctl.CreateInstance(ctx, createReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create standalone instance: %w", err)
|
|
}
|
|
|
|
// Create agentctl client pointing to the instance port
|
|
client := agentctl.NewClient(r.host, resp.Port, r.logger,
|
|
agentctl.WithExecutionID(req.InstanceID),
|
|
agentctl.WithSessionID(req.SessionID),
|
|
agentctl.WithAuthToken(r.authToken))
|
|
|
|
// Extract runtime-specific values from metadata
|
|
worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID)
|
|
worktreeBranch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch)
|
|
|
|
// Build metadata
|
|
metadata := make(map[string]interface{})
|
|
metadata["standalone_port"] = resp.Port
|
|
if worktreeID != "" {
|
|
metadata["worktree_id"] = worktreeID
|
|
metadata["worktree_path"] = req.WorkspacePath
|
|
metadata["worktree_branch"] = worktreeBranch
|
|
}
|
|
|
|
r.logger.Debug("standalone instance created",
|
|
zap.String("instance_id", req.InstanceID),
|
|
zap.Int("port", resp.Port),
|
|
zap.String("workspace", req.WorkspacePath))
|
|
|
|
return &ExecutorInstance{
|
|
InstanceID: req.InstanceID,
|
|
TaskID: req.TaskID,
|
|
SessionID: req.SessionID,
|
|
RuntimeName: r.Name(),
|
|
Client: client,
|
|
StandaloneInstanceID: resp.ID,
|
|
StandalonePort: resp.Port,
|
|
WorkspacePath: req.WorkspacePath,
|
|
Metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
func (r *StandaloneExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error {
|
|
if instance.StandaloneInstanceID == "" {
|
|
return nil // No standalone instance to stop
|
|
}
|
|
|
|
if err := r.ctl.DeleteInstance(ctx, instance.StandaloneInstanceID); err != nil {
|
|
return fmt.Errorf("failed to stop standalone instance: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *StandaloneExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) {
|
|
// Standalone instances are not persisted - they are transient processes
|
|
// managed by agentctl. Session resume will restart them as needed.
|
|
return nil, nil
|
|
}
|
|
|
|
// SetInteractiveRunner sets the interactive runner for passthrough mode.
|
|
func (r *StandaloneExecutor) SetInteractiveRunner(runner *process.InteractiveRunner) {
|
|
r.interactiveRunner = runner
|
|
}
|
|
|
|
// GetInteractiveRunner returns the interactive runner for passthrough mode.
|
|
func (r *StandaloneExecutor) GetInteractiveRunner() *process.InteractiveRunner {
|
|
return r.interactiveRunner
|
|
}
|
|
|
|
func (r *StandaloneExecutor) RequiresCloneURL() bool { return false }
|
|
func (r *StandaloneExecutor) ShouldApplyPreferredShell() bool { return true }
|
|
func (r *StandaloneExecutor) IsAlwaysResumable() bool { return false }
|