// 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 ( "context" "sync" "sync/atomic" "time" mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" ) type processManager interface { CloseAdmission() StopForTeardown(context.Context) error // WorkspaceSourceRoots returns the live, current source-root allowlist a // rescan/rebind may have changed since creation (AC-EXECUTORS-SURVIVAL- // 002.14's "workspace source roots" reconstruction row). WorkspaceSourceRoots() []string // GetSessionID returns the provider's own session identity, held by the // live adapter (AC-EXECUTORS-SURVIVAL-002.14's "provider session // identity" reconstruction row). Empty before the agent's session/new or // session/load completes. GetSessionID() string } // 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 // SessionID is the task session ID this instance was created for, if any. // Used to correlate a recovered instance back to its owning session. SessionID string // TaskID is the task ID this instance was created for, if any. TaskID string // manager is the process manager handling the agent subprocess (unexported) manager processManager // server is the HTTP server for this instance's API (unexported) server instanceHTTPServer // 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 stopMu sync.Mutex statusMu sync.RWMutex portReleased bool // turnOutcome holds this instance's single retained last-terminal-turn // slot (AC-EXECUTORS-SURVIVAL-004.1/.6). Zero value is ready to use -- // no outcome retained. turnOutcome turnOutcomeState } // 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). 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"` // AutoApprovePermissions controls agentctl-side permission auto-approval // for this instance. Nil uses the control server default. AutoApprovePermissions *bool `json:"auto_approve_permissions,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), // "task-title-pending", "config", "office", or "automation". McpMode string `json:"mcp_mode,omitempty"` // McpProviders limits task-mode review automation tools to attached providers. McpProviders []string `json:"mcp_providers,omitempty"` McpProfile *mcpprofile.Context `json:"mcp_profile,omitempty"` // NamespacesMCPToolsByServer enables the per-instance MCP name adapter for // clients that append the injected server name to every tool. NamespacesMCPToolsByServer bool `json:"namespaces_mcp_tools_by_server,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"` // StripEnv lists environment variables to strip from the agent's child // process environment entirely (not just set to empty). StripEnv []string `json:"strip_env,omitempty"` // BaseBranches maps RepositoryName → base branch ref for per-repo diff // stats. The empty key "" applies to the root / single-repo tracker. // Each WorkspaceTracker reads its entry at startup and uses it as the // first candidate when resolving BaseCommit / Ahead / Behind. Empty // disables the override. BaseBranches map[string]string `json:"base_branches,omitempty"` ComparisonTargets map[string]models.ComparisonTarget `json:"comparison_targets,omitempty"` RemoteContributions map[string]models.RemoteContribution `json:"remote_contributions,omitempty"` ContributionDestinations map[string]models.ContributionDestination `json:"contribution_destinations,omitempty"` WorkspaceSourceRoots []string `json:"workspace_source_roots,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"` // SessionID is the task session ID this instance was created for, if any. SessionID string `json:"session_id,omitempty"` // TaskID is the task ID this instance was created for, if any. TaskID string `json:"task_id,omitempty"` // WorkspaceSourceRoots is the live, current source-root allowlist this // instance is enforcing right now -- a rescan or rebind can change it // after creation, so this always reflects that, not a creation-time // snapshot (AC-EXECUTORS-SURVIVAL-002.14's "workspace source roots" // reconstruction row: read back, never pushed). WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` // ProviderSessionID is the live agent CLI's own session identity, held by // the adapter (AC-EXECUTORS-SURVIVAL-002.14's "provider session identity" // reconstruction row: the adopted instance, which holds the provider // session -- never the database). Distinct from SessionID above, which is // this Kandev task session's identity, not the underlying agent's. ProviderSessionID string `json:"provider_session_id,omitempty"` } // 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 { i.statusMu.RLock() defer i.statusMu.RUnlock() // 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 } } var sourceRoots []string var providerSessionID string if i.manager != nil { sourceRoots = i.manager.WorkspaceSourceRoots() providerSessionID = i.manager.GetSessionID() } return &InstanceInfo{ ID: i.ID, Port: i.Port, Status: i.Status, WorkspacePath: i.WorkspacePath, AgentCommand: i.AgentCommand, Env: envCopy, CreatedAt: i.CreatedAt, SessionID: i.SessionID, TaskID: i.TaskID, WorkspaceSourceRoots: sourceRoots, ProviderSessionID: providerSessionID, } } func (i *Instance) setStatus(status string) { i.statusMu.Lock() i.Status = status i.statusMu.Unlock() }