// Package lifecycle manages agent instance lifecycles including tracking, // state transitions, and cleanup. package lifecycle import ( "context" "errors" "fmt" "path/filepath" "strings" "sync" "time" "github.com/google/uuid" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/runtime" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/process" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/appctx" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" ) // RuntimeFallbackPolicy controls behavior when a requested runtime is unavailable. type RuntimeFallbackPolicy string const ( // RuntimeFallbackAllow silently falls back to the default runtime (current behavior). RuntimeFallbackAllow RuntimeFallbackPolicy = "allow" // RuntimeFallbackWarn falls back but logs a warning (current behavior, explicit). RuntimeFallbackWarn RuntimeFallbackPolicy = "warn" // RuntimeFallbackDeny returns an error if the requested runtime is unavailable. RuntimeFallbackDeny RuntimeFallbackPolicy = "deny" ) // Manager manages agent instance lifecycles type Manager struct { registry *registry.Registry eventBus bus.EventBus credsMgr CredentialsManager profileResolver ProfileResolver worktreeMgr *worktree.Manager mcpProvider McpConfigProvider logger *logger.Logger // RuntimeRegistry manages multiple runtimes (Docker, Standalone, etc.) // Each task can select its runtime based on executor type. runtimeRegistry *RuntimeRegistry // runtimeFallbackPolicy controls behavior when a requested runtime is unavailable. runtimeFallbackPolicy RuntimeFallbackPolicy // Refactored components for separation of concerns executionStore *ExecutionStore // Thread-safe execution tracking commandBuilder *CommandBuilder // Builds agent commands from registry config sessionManager *SessionManager // Handles ACP session initialization streamManager *StreamManager // Manages WebSocket streams eventPublisher *EventPublisher // Publishes lifecycle events containerManager *ContainerManager // Manages Docker containers (optional, nil for non-Docker runtimes) historyManager *SessionHistoryManager // Stores session history for context injection (fork_session pattern) // Workspace info provider for on-demand instance creation workspaceInfoProvider WorkspaceInfoProvider // bootMessageService creates boot messages displayed in chat during agent startup. bootMessageService BootMessageService // mcpHandler is the MCP request dispatcher for handling MCP requests // from agentctl instances through the agent stream. mcpHandler agentctl.MCPHandler // Background cleanup cleanupInterval time.Duration stopCh chan struct{} wg sync.WaitGroup } // NewManager creates a new lifecycle manager. // The runtimeRegistry manages multiple runtimes (Docker, Standalone, etc.) for task-specific execution. // The containerManager parameter is optional and used for Docker cleanup (pass nil for non-Docker runtimes). // The fallbackPolicy controls behavior when a requested runtime is unavailable. func NewManager( reg *registry.Registry, eventBus bus.EventBus, runtimeRegistry *RuntimeRegistry, containerManager *ContainerManager, credsMgr CredentialsManager, profileResolver ProfileResolver, mcpProvider McpConfigProvider, fallbackPolicy RuntimeFallbackPolicy, log *logger.Logger, ) *Manager { componentLogger := log.WithFields(zap.String("component", "lifecycle-manager")) // Initialize command builder commandBuilder := NewCommandBuilder() // Create stop channel for graceful shutdown stopCh := make(chan struct{}) // Initialize session manager sessionManager := NewSessionManager(log, stopCh) // Initialize event publisher eventPublisher := NewEventPublisher(eventBus, log) // Initialize execution store executionStore := NewExecutionStore() // Initialize session history manager for fork_session pattern (context injection) historyManager, err := NewSessionHistoryManager("", log) if err != nil { log.Warn("failed to create session history manager, context injection disabled", zap.Error(err)) } mgr := &Manager{ registry: reg, eventBus: eventBus, runtimeRegistry: runtimeRegistry, runtimeFallbackPolicy: fallbackPolicy, credsMgr: credsMgr, profileResolver: profileResolver, mcpProvider: mcpProvider, logger: componentLogger, executionStore: executionStore, commandBuilder: commandBuilder, sessionManager: sessionManager, eventPublisher: eventPublisher, containerManager: containerManager, historyManager: historyManager, cleanupInterval: 30 * time.Second, stopCh: stopCh, } // Initialize stream manager with callbacks that delegate to manager methods // mcpHandler will be set later via SetMCPHandler mgr.streamManager = NewStreamManager(log, StreamCallbacks{ OnAgentEvent: mgr.handleAgentEvent, OnGitStatus: mgr.handleGitStatusUpdate, OnGitCommit: mgr.handleGitCommitCreated, OnGitReset: mgr.handleGitResetDetected, OnFileChange: mgr.handleFileChangeNotification, OnShellOutput: mgr.handleShellOutput, OnShellExit: mgr.handleShellExit, OnProcessOutput: mgr.handleProcessOutput, OnProcessStatus: mgr.handleProcessStatus, }, nil) // Set session manager dependencies for full orchestration sessionManager.SetDependencies(eventPublisher, mgr.streamManager, executionStore, historyManager) if runtimeRegistry != nil { mgr.logger.Info("initialized with runtimes", zap.Int("count", len(runtimeRegistry.List()))) } return mgr } // SetWorktreeManager sets the worktree manager for Git worktree isolation. // // This must be called before launching agents if Git worktree support is enabled in the runtime. // The worktree manager creates isolated Git working directories for each agent execution, // allowing multiple agents to work on the same repository without conflicts. // // Call this during initialization, typically when setting up the orchestrator service. // If not set, agents will work directly in the repository's main working directory. func (m *Manager) SetWorktreeManager(worktreeMgr *worktree.Manager) { m.worktreeMgr = worktreeMgr } // SetMCPHandler sets the MCP request handler for dispatching MCP tool calls. // // MCP requests from agents flow through the agent stream (WebSocket) to the backend, // where they are dispatched to this handler. This enables agents to use MCP tools // like listing workspaces, boards, tasks, and asking user questions. // // This must be called before agents start making MCP calls. Typically set during // initialization after the MCP handlers are created. func (m *Manager) SetMCPHandler(handler agentctl.MCPHandler) { m.mcpHandler = handler // Update the stream manager with the handler m.streamManager.mcpHandler = handler } // SetWorkspaceInfoProvider sets the provider for workspace information. // // The WorkspaceInfoProvider interface allows the lifecycle manager to dynamically create // agent executions on-demand when the frontend connects to a session that doesn't have // an active execution yet. This enables session resume after server restart or when // accessing a session via URL (/task/[id]/[sessionId]). // // The provider must implement: // - GetWorkspaceInfoBySessionID(ctx, sessionID) - Returns workspace path, worktree info, // and MCP servers configured for the session // // This is typically called during initialization, with the task service as the provider. // Without this, EnsureWorkspaceExecutionForSession will fail. func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) { m.workspaceInfoProvider = provider } // SetBootMessageService sets the service used to create boot messages in chat // during agent startup. If not set, no boot messages will be created. func (m *Manager) SetBootMessageService(svc BootMessageService) { m.bootMessageService = svc } // getRuntimeForExecutorType returns the appropriate runtime for the given executor type. // If the executor type is empty or the runtime is not available, behavior depends on runtimeFallbackPolicy. func (m *Manager) getRuntimeForExecutorType(executorType string) (Runtime, error) { if m.runtimeRegistry == nil { return nil, fmt.Errorf("no runtime registry configured") } if executorType != "" { runtimeName := runtime.ExecutorTypeToRuntime(models.ExecutorType(executorType)) rt, err := m.runtimeRegistry.GetRuntime(runtimeName) if err == nil { return rt, nil } // Handle fallback based on policy switch m.runtimeFallbackPolicy { case RuntimeFallbackDeny: return nil, fmt.Errorf("runtime %s not available and fallback is denied: %w", runtimeName, err) case RuntimeFallbackWarn: m.logger.Warn("requested runtime not available, falling back to default", zap.String("executor_type", executorType), zap.String("runtime", string(runtimeName)), zap.Error(err)) case RuntimeFallbackAllow: m.logger.Debug("requested runtime not available, falling back to default", zap.String("executor_type", executorType), zap.String("runtime", string(runtimeName))) default: // Default to warn behavior for backwards compatibility m.logger.Warn("requested runtime not available, falling back to default", zap.String("executor_type", executorType), zap.String("runtime", string(runtimeName)), zap.Error(err)) } } return m.runtimeRegistry.GetRuntime(runtime.NameStandalone) } // getDefaultRuntime returns the default runtime (standalone). // This is used when no executor type is specified. func (m *Manager) getDefaultRuntime() (Runtime, error) { if m.runtimeRegistry == nil { return nil, fmt.Errorf("no runtime registry configured") } return m.runtimeRegistry.GetRuntime(runtime.NameStandalone) } // EnsureWorkspaceExecutionForSession ensures an agentctl execution exists for a specific task session. // This is used when the frontend provides a session ID (e.g., from URL path /task/[id]/[sessionId]). // If an execution already exists for the session, it returns it. Otherwise, it creates a new execution // using the session's workspace configuration from the database. func (m *Manager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) (*AgentExecution, error) { // Check if execution already exists if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { return execution, nil } // Need to create a new execution - get workspace info for the specific session if m.workspaceInfoProvider == nil { return nil, fmt.Errorf("workspace info provider not configured") } info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, taskID, sessionID) if err != nil { return nil, fmt.Errorf("failed to get workspace info for session %s: %w", sessionID, err) } if info.WorkspacePath == "" { return nil, fmt.Errorf("no workspace path available for session %s", sessionID) } m.logger.Info("creating execution for task session", zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.String("workspace_path", info.WorkspacePath), zap.String("acp_session_id", info.ACPSessionID)) execution, err := m.createExecution(ctx, taskID, info) if err != nil { return nil, err } // For workspace-only executions (no agent), wait for agentctl to be ready // then connect the workspace stream so process output can be received go func() { // Use detached context that respects stopCh for graceful shutdown waitCtx, cancel := appctx.Detached(ctx, m.stopCh, 60*time.Second) defer cancel() if err := execution.agentctl.WaitForReady(waitCtx, 60*time.Second); err != nil { m.logger.Error("agentctl not ready for workspace stream connection", zap.String("execution_id", execution.ID), zap.Error(err)) return } // Connect workspace stream for process output (agent stream not needed for workspace-only) if m.streamManager != nil { m.logger.Info("connecting workspace stream for workspace-only execution", zap.String("execution_id", execution.ID)) go m.streamManager.connectWorkspaceStream(execution, nil) } }() return execution, nil } // EnsurePassthroughExecution ensures an execution exists for a passthrough session // and starts the passthrough process if needed. This is called when the terminal // handler receives a connection for a session that might need recovery after backend restart. // // The sessionID is required. If taskID is empty, it will be looked up from: // 1. The existing execution (if any) // 2. The workspace info provider // // Returns the execution with a running passthrough process, or an error. func (m *Manager) EnsurePassthroughExecution(ctx context.Context, sessionID string) (*AgentExecution, error) { // Check if execution already exists with a running passthrough process if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { if execution.PassthroughProcessID != "" { return execution, nil } // Execution exists but no passthrough process - will try to start it return m.resumeExistingExecution(ctx, sessionID, execution) } // No execution exists - need to create one from session info return m.createExecutionFromSessionInfo(ctx, sessionID) } // resumeExistingExecution starts the passthrough process for an existing execution // that has no running process (e.g., after backend restart). func (m *Manager) resumeExistingExecution(ctx context.Context, sessionID string, execution *AgentExecution) (*AgentExecution, error) { m.logger.Info("execution exists but passthrough process not running, starting", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID)) if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { return nil, fmt.Errorf("resume passthrough session %s: %w", sessionID, err) } // Get updated execution with process ID execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return nil, fmt.Errorf("execution disappeared after resuming passthrough session %s", sessionID) } return execution, nil } // createExecutionFromSessionInfo creates a new execution for a passthrough session // when no execution exists (e.g., backend restarted and execution store was cleared). func (m *Manager) createExecutionFromSessionInfo(ctx context.Context, sessionID string) (*AgentExecution, error) { if m.workspaceInfoProvider == nil { return nil, fmt.Errorf("cannot restore session %s: workspace info provider not configured", sessionID) } // Get workspace info from the provider (looks up session to get taskID, workspace path, etc.) info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, "", sessionID) if err != nil { return nil, fmt.Errorf("get workspace info for session %s: %w", sessionID, err) } if info.WorkspacePath == "" { return nil, fmt.Errorf("session %s has no workspace path configured", sessionID) } if info.TaskID == "" { return nil, fmt.Errorf("session %s has no associated task ID", sessionID) } // Verify this session should use passthrough mode if err := m.verifyPassthroughEnabled(ctx, sessionID, info.AgentProfileID); err != nil { return nil, err } // Create the execution m.logger.Info("creating execution for passthrough session", zap.String("task_id", info.TaskID), zap.String("session_id", sessionID), zap.String("workspace_path", info.WorkspacePath)) execution, err := m.createExecution(ctx, info.TaskID, info) if err != nil { return nil, fmt.Errorf("create execution for session %s: %w", sessionID, err) } // Start the passthrough process using resume command (recovery after restart) m.logger.Info("starting passthrough process for session", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID)) if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { return nil, fmt.Errorf("start passthrough process for session %s: %w", sessionID, err) } // Get updated execution with process ID execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return nil, fmt.Errorf("execution disappeared after starting passthrough session %s", sessionID) } return execution, nil } // verifyPassthroughEnabled checks if the session's profile has CLI passthrough enabled. func (m *Manager) verifyPassthroughEnabled(ctx context.Context, sessionID, profileID string) error { if m.profileResolver == nil || profileID == "" { return fmt.Errorf("session %s has no profile configured for passthrough mode", sessionID) } profileInfo, err := m.profileResolver.ResolveProfile(ctx, profileID) if err != nil { m.logger.Warn("failed to resolve profile for passthrough check", zap.String("session_id", sessionID), zap.String("profile_id", profileID), zap.Error(err)) return fmt.Errorf("session %s: failed to resolve profile %s: %w", sessionID, profileID, err) } if profileInfo == nil || !profileInfo.CLIPassthrough { return fmt.Errorf("session %s is not configured for CLI passthrough mode", sessionID) } return nil } // createExecution creates an agentctl execution. // The agent subprocess is NOT started - call ConfigureAgent + Start explicitly. func (m *Manager) createExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) { // Get the default runtime for on-demand execution creation rt, err := m.getDefaultRuntime() if err != nil { return nil, fmt.Errorf("no runtime configured: %w", err) } if info.AgentID == "" { return nil, fmt.Errorf("agent ID is required in WorkspaceInfo") } executionID := uuid.New().String() agentConfig, ok := m.registry.Get(info.AgentID) if !ok { return nil, fmt.Errorf("agent type %q not found in registry", info.AgentID) } req := &RuntimeCreateRequest{ InstanceID: executionID, TaskID: taskID, SessionID: info.SessionID, AgentProfileID: info.AgentProfileID, WorkspacePath: info.WorkspacePath, Protocol: string(agentConfig.Runtime().Protocol), AgentConfig: agentConfig, } runtimeInstance, err := rt.CreateInstance(ctx, req) if err != nil { return nil, fmt.Errorf("failed to create execution: %w", err) } execution := runtimeInstance.ToAgentExecution(req) execution.RuntimeName = string(rt.Name()) // Set the ACP session ID for session resumption if info.ACPSessionID != "" { execution.ACPSessionID = info.ACPSessionID } m.executionStore.Add(execution) go m.waitForAgentctlReady(execution) m.logger.Info("execution created", zap.String("execution_id", executionID), zap.String("task_id", taskID), zap.String("workspace_path", info.WorkspacePath), zap.String("runtime", execution.RuntimeName)) return execution, nil } // Start starts the lifecycle manager background tasks func (m *Manager) Start(ctx context.Context) error { if m.runtimeRegistry == nil { m.logger.Warn("no runtime registry configured") return nil } runtimeNames := m.runtimeRegistry.List() m.logger.Info("starting lifecycle manager", zap.Int("runtimes", len(runtimeNames))) // Check health of all registered runtimes healthResults := m.runtimeRegistry.HealthCheckAll(ctx) for name, err := range healthResults { if err != nil { m.logger.Warn("runtime health check failed", zap.String("runtime", string(name)), zap.Error(err)) } else { m.logger.Info("runtime is healthy", zap.String("runtime", string(name))) } } // Try to recover executions from all runtimes recovered, err := m.runtimeRegistry.RecoverAll(ctx) if err != nil { m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err)) } if len(recovered) > 0 { for _, ri := range recovered { execution := &AgentExecution{ ID: ri.InstanceID, TaskID: ri.TaskID, SessionID: ri.SessionID, ContainerID: ri.ContainerID, ContainerIP: ri.ContainerIP, WorkspacePath: ri.WorkspacePath, RuntimeName: ri.RuntimeName, Status: v1.AgentStatusRunning, StartedAt: time.Now(), Metadata: ri.Metadata, agentctl: ri.Client, standaloneInstanceID: ri.StandaloneInstanceID, standalonePort: ri.StandalonePort, promptDoneCh: make(chan PromptCompletionSignal, 1), } m.executionStore.Add(execution) // Reconnect to workspace streams (shell, git, file changes) in background // This is needed so shell.input, git status, etc. work after backend restart go m.streamManager.ReconnectAll(execution) } m.logger.Info("recovered executions", zap.Int("count", len(recovered))) } // Start cleanup loop when container manager is available (Docker mode) if m.containerManager != nil { m.wg.Add(1) go m.cleanupLoop(ctx) m.logger.Info("cleanup loop started") } // Set up callbacks for passthrough mode (using standalone runtime) if standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone); err == nil { if interactiveRunner := standaloneRT.GetInteractiveRunner(); interactiveRunner != nil { // Turn complete callback interactiveRunner.SetTurnCompleteCallback(func(sessionID string) { m.handlePassthroughTurnComplete(sessionID) }) // Output callback for standalone passthrough (no WorkspaceTracker) interactiveRunner.SetOutputCallback(func(output *agentctltypes.ProcessOutput) { m.handlePassthroughOutput(output) }) // Status callback for standalone passthrough (no WorkspaceTracker) interactiveRunner.SetStatusCallback(func(status *agentctltypes.ProcessStatusUpdate) { m.handlePassthroughStatus(status) }) m.logger.Info("passthrough callbacks configured") } } return nil } // GetRecoveredExecutions returns a snapshot of all currently tracked executions // This can be used by the orchestrator to sync with the database func (m *Manager) GetRecoveredExecutions() []RecoveredExecution { executions := m.executionStore.List() result := make([]RecoveredExecution, 0, len(executions)) for _, exec := range executions { result = append(result, RecoveredExecution{ ExecutionID: exec.ID, TaskID: exec.TaskID, SessionID: exec.SessionID, ContainerID: exec.ContainerID, AgentProfileID: exec.AgentProfileID, }) } return result } // Stop stops the lifecycle manager func (m *Manager) Stop() error { m.logger.Info("stopping lifecycle manager") close(m.stopCh) m.wg.Wait() return nil } // StopAllAgents attempts a graceful shutdown of all active agents concurrently. func (m *Manager) StopAllAgents(ctx context.Context) error { executions := m.executionStore.List() if len(executions) == 0 { return nil } var wg sync.WaitGroup errCh := make(chan error, len(executions)) for _, exec := range executions { wg.Add(1) go func(e *AgentExecution) { defer wg.Done() if err := m.StopAgent(ctx, e.ID, false); err != nil { errCh <- err m.logger.Warn("failed to stop agent during shutdown", zap.String("execution_id", e.ID), zap.Error(err)) } }(exec) } wg.Wait() close(errCh) var errs []error for err := range errCh { errs = append(errs, err) } return errors.Join(errs...) } // Launch launches a new agent for a task func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) { m.logger.Debug("launching agent", zap.String("task_id", req.TaskID), zap.String("agent_profile_id", req.AgentProfileID), zap.Bool("use_worktree", req.UseWorktree)) // 1. Resolve the agent profile to get agent type info var agentTypeName string var profileInfo *AgentProfileInfo var err error if m.profileResolver != nil { profileInfo, err = m.profileResolver.ResolveProfile(ctx, req.AgentProfileID) if err != nil { return nil, fmt.Errorf("failed to resolve agent profile: %w", err) } agentTypeName = profileInfo.AgentName m.logger.Debug("resolved agent profile", zap.String("profile_id", req.AgentProfileID), zap.String("agent_name", profileInfo.AgentName), zap.String("agent_type", agentTypeName)) } else { // Fallback: treat AgentProfileID as agent type directly (for backward compat) agentTypeName = req.AgentProfileID m.logger.Warn("no profile resolver configured, using profile ID as agent type", zap.String("agent_type", agentTypeName)) } // 2. Get agent config from registry agentConfig, ok := m.registry.Get(agentTypeName) if !ok { return nil, fmt.Errorf("agent type %q not found in registry", agentTypeName) } if !agentConfig.Enabled() { return nil, fmt.Errorf("agent type %q is disabled", agentTypeName) } // 3. Check if session already has an agent running if req.SessionID != "" { if existingExecution, exists := m.executionStore.GetBySessionID(req.SessionID); exists { return nil, fmt.Errorf("session %q already has an agent running (execution: %s)", req.SessionID, existingExecution.ID) } } // 4. Handle worktree creation/reuse if enabled workspacePath := req.WorkspacePath var mainRepoGitDir string // Path to main repo's .git directory for container mount var worktreeID string // Store worktree ID for session resumption var worktreeBranch string // Store worktree branch for API responses if req.UseWorktree && m.worktreeMgr != nil && req.RepositoryPath != "" { wt, err := m.getOrCreateWorktree(ctx, req) if err != nil { m.logger.Warn("failed to create worktree, falling back to direct mount", zap.String("task_id", req.TaskID), zap.Error(err)) // Fall back to direct mount if worktree creation fails // Use RepositoryPath as the workspace workspacePath = req.RepositoryPath } else { workspacePath = wt.Path worktreeID = wt.ID worktreeBranch = wt.Branch // Git worktrees reference the main repo's .git directory via a .git file // The worktree metadata in .git/worktrees/{name} contains a 'commondir' file // that points back to the main .git directory (usually '../..') // We need to mount the entire .git directory for git commands to work mainRepoGitDir = filepath.Join(req.RepositoryPath, ".git") } } else if req.RepositoryPath != "" && workspacePath == "" { // No worktree, but we have a repository path - use it as workspace workspacePath = req.RepositoryPath } // 5. Generate a new execution ID executionID := uuid.New().String() // 6. Prepare request with worktree path reqWithWorktree := *req reqWithWorktree.WorkspacePath = workspacePath // Store task description in metadata for StartAgentProcess if reqWithWorktree.Metadata == nil { reqWithWorktree.Metadata = make(map[string]interface{}) } if req.TaskDescription != "" { reqWithWorktree.Metadata["task_description"] = req.TaskDescription } if req.SessionID != "" { reqWithWorktree.Metadata["session_id"] = req.SessionID } // 6b. Add profile settings to environment if profileInfo != nil { if reqWithWorktree.Env == nil { reqWithWorktree.Env = make(map[string]string) } if profileInfo.Model != "" { reqWithWorktree.Env["AGENT_MODEL"] = profileInfo.Model } if profileInfo.AutoApprove { reqWithWorktree.Env["AGENTCTL_AUTO_APPROVE_PERMISSIONS"] = "true" } } // 7. Launch via runtime - creates agentctl instance with workspace access only // Agent subprocess is NOT started - call StartAgentProcess() explicitly // Select runtime based on executor type from request rt, err := m.getRuntimeForExecutorType(req.ExecutorType) if err != nil { return nil, fmt.Errorf("no runtime configured: %w", err) } // Build environment variables env := m.buildEnvForRuntime(executionID, &reqWithWorktree, agentConfig) // Resolve MCP servers early so they're available for protocols that need them at startup (e.g., Codex) acpMcpServers, err := m.resolveMcpServersWithParams(ctx, reqWithWorktree.AgentProfileID, reqWithWorktree.Metadata, agentConfig) if err != nil { m.logger.Warn("failed to resolve MCP servers for launch", zap.Error(err)) // Continue without MCP servers - not a fatal error } // Convert ACP MCP servers to runtime config format var mcpServers []McpServerConfig for _, srv := range acpMcpServers { mcpServers = append(mcpServers, McpServerConfig{ Name: srv.Name, URL: srv.URL, Type: srv.Type, Command: srv.Command, Args: srv.Args, }) } // Build metadata with runtime-specific fields metadata := make(map[string]interface{}) if reqWithWorktree.Metadata != nil { for k, v := range reqWithWorktree.Metadata { metadata[k] = v } } if mainRepoGitDir != "" { metadata[MetadataKeyMainRepoGitDir] = mainRepoGitDir } if worktreeID != "" { metadata[MetadataKeyWorktreeID] = worktreeID } if worktreeBranch != "" { metadata[MetadataKeyWorktreeBranch] = worktreeBranch } // Create runtime request (agent command not included - started explicitly later) runtimeReq := &RuntimeCreateRequest{ InstanceID: executionID, TaskID: reqWithWorktree.TaskID, SessionID: reqWithWorktree.SessionID, AgentProfileID: reqWithWorktree.AgentProfileID, WorkspacePath: reqWithWorktree.WorkspacePath, Protocol: string(agentConfig.Runtime().Protocol), Env: env, Metadata: metadata, AgentConfig: agentConfig, McpServers: mcpServers, } runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq) if err != nil { return nil, fmt.Errorf("failed to create execution: %w", err) } // Convert to AgentExecution and set the runtime name execution := runtimeInstance.ToAgentExecution(runtimeReq) execution.RuntimeName = string(rt.Name()) // Set ACP session ID for session resumption (used by InitializeSession) if req.ACPSessionID != "" { execution.ACPSessionID = req.ACPSessionID } // Build agent command string for later use with StartAgentProcess model := "" autoApprove := false permissionValues := make(map[string]bool) if profileInfo != nil { model = profileInfo.Model autoApprove = profileInfo.AutoApprove // Build permission values map from profile info permissionValues["auto_approve"] = profileInfo.AutoApprove permissionValues["allow_indexing"] = profileInfo.AllowIndexing permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions } // Allow model override from request (for dynamic model switching) if req.ModelOverride != "" { model = req.ModelOverride } cmdOpts := agents.CommandOptions{ Model: model, SessionID: req.ACPSessionID, AutoApprove: autoApprove, PermissionValues: permissionValues, } execution.AgentCommand = m.commandBuilder.BuildCommandString(agentConfig, cmdOpts) // 8. Track the execution m.executionStore.Add(execution) // 9. Publish agent.started event m.eventPublisher.PublishAgentEvent(ctx, events.AgentStarted, execution) m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlStarting, execution, "") // 10. Wait for agentctl to be ready (for shell/workspace access) // NOTE: This does NOT start the agent process - call StartAgentProcess() explicitly go m.waitForAgentctlReady(execution) m.logger.Debug("agentctl execution created (agent not started)", zap.String("execution_id", executionID), zap.String("task_id", req.TaskID), zap.String("runtime", execution.RuntimeName)) return execution, nil } // SetExecutionDescription updates the task description stored in an execution's metadata. // This is used when starting an agent on a workspace that was launched without a prompt. func (m *Manager) SetExecutionDescription(_ context.Context, executionID string, description string) error { execution, exists := m.executionStore.Get(executionID) if !exists { return fmt.Errorf("execution %q not found", executionID) } if execution.Metadata == nil { execution.Metadata = make(map[string]interface{}) } execution.Metadata["task_description"] = description return nil } // StartAgentProcess configures and starts the agent subprocess for an execution. // This must be called after Launch() to actually start the agent (e.g., auggie, codex). // The command is built internally based on the execution's agent profile. func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) error { execution, exists := m.executionStore.Get(executionID) if !exists { return fmt.Errorf("execution %q not found", executionID) } // Check if this execution should use passthrough mode if execution.AgentProfileID != "" && m.profileResolver != nil { profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) if err == nil && profileInfo.CLIPassthrough { return m.startPassthroughSession(ctx, execution, profileInfo) } } if execution.agentctl == nil { return fmt.Errorf("execution %q has no agentctl client", executionID) } if execution.AgentCommand == "" { return fmt.Errorf("execution %q has no agent command configured", executionID) } // Wait for agentctl to be ready if err := execution.agentctl.WaitForReady(ctx, 60*time.Second); err != nil { m.updateExecutionError(executionID, "agentctl not ready: "+err.Error()) return fmt.Errorf("agentctl not ready: %w", err) } // Get task description from metadata taskDescription := "" if execution.Metadata != nil { if desc, ok := execution.Metadata["task_description"].(string); ok { taskDescription = desc } } m.logger.Warn("StartAgentProcess: task description resolved", zap.String("execution_id", executionID), zap.String("task_id", execution.TaskID), zap.Int("task_description_length", len(taskDescription)), zap.String("agent_command", execution.AgentCommand), zap.String("acp_session_id", execution.ACPSessionID)) // Build environment for the agent process env := map[string]string{} if taskDescription != "" { env["TASK_DESCRIPTION"] = taskDescription } // Determine approval policy and resolve agent display name from profile + registry approvalPolicy := "" agentDisplayName := "" if execution.AgentProfileID != "" && m.profileResolver != nil { if profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID); err == nil { if profileInfo.AutoApprove { approvalPolicy = "never" } else { approvalPolicy = "untrusted" } // Look up display name from registry (e.g. "Claude", "Auggie", "Codex") if agentConfig, ok := m.registry.Get(profileInfo.AgentName); ok && agentConfig.DisplayName() != "" { agentDisplayName = agentConfig.DisplayName() } else { agentDisplayName = profileInfo.AgentName } } } // Configure the agent command if err := execution.agentctl.ConfigureAgent(ctx, execution.AgentCommand, env, approvalPolicy); err != nil { return fmt.Errorf("failed to configure agent: %w", err) } // Start the agent process fullCommand, err := execution.agentctl.Start(ctx) if err != nil { m.updateExecutionError(executionID, "failed to start agent: "+err.Error()) return fmt.Errorf("failed to start agent: %w", err) } // Use the full command (with adapter args) if available, otherwise fall back to the base command bootCommand := fullCommand if bootCommand == "" { bootCommand = execution.AgentCommand } m.logger.Info("agent process started", zap.String("execution_id", executionID), zap.String("task_id", execution.TaskID), zap.String("command", bootCommand)) // Create boot message to show agent boot progress in chat var bootMsg *models.Message var bootStopCh chan struct{} if m.bootMessageService != nil { var bootErr error bootMsg, bootErr = m.bootMessageService.CreateMessage(ctx, &BootMessageRequest{ TaskSessionID: execution.SessionID, TaskID: execution.TaskID, Content: "", AuthorType: "agent", Type: "script_execution", Metadata: map[string]interface{}{ "script_type": "agent_boot", "agent_name": agentDisplayName, "command": bootCommand, "status": "running", "is_resuming": execution.ACPSessionID != "", "started_at": time.Now().UTC().Format(time.RFC3339Nano), }, }) if bootErr != nil { m.logger.Warn("failed to create boot message, continuing without boot output", zap.String("execution_id", executionID), zap.Error(bootErr)) } else { // Start polling goroutine for stderr bootStopCh = make(chan struct{}) go m.pollAgentStderr(execution.agentctl, bootMsg, bootStopCh) } } // Give the agent process a moment to initialize time.Sleep(500 * time.Millisecond) // Get agent config for ACP session initialization agentConfig, err := m.getAgentConfigForExecution(execution) if err != nil { m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") return fmt.Errorf("failed to get agent config: %w", err) } mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig) if err != nil { m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") m.updateExecutionError(executionID, "failed to resolve MCP config: "+err.Error()) return fmt.Errorf("failed to resolve MCP config: %w", err) } // Initialize ACP session. // Session status events are now emitted by all adapters via the stream. if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, mcpServers); err != nil { m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") m.updateExecutionError(executionID, "failed to initialize ACP: "+err.Error()) return fmt.Errorf("failed to initialize ACP: %w", err) } // Finalize boot message as successful m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "exited") return nil } // pollAgentStderr polls the agent's stderr buffer every 2 seconds and updates the boot message. func (m *Manager) pollAgentStderr(client *agentctl.Client, msg *models.Message, stopCh chan struct{}) { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() var lastLineCount int for { select { case <-stopCh: return case <-ticker.C: ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) lines, err := client.GetAgentStderr(ctx) cancel() if err != nil { m.logger.Debug("failed to poll agent stderr", zap.Error(err)) continue } if len(lines) > lastLineCount { lastLineCount = len(lines) msg.Content = strings.Join(lines, "\n") ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) if updateErr := m.bootMessageService.UpdateMessage(ctx2, msg); updateErr != nil { m.logger.Debug("failed to update boot message with stderr", zap.String("message_id", msg.ID), zap.Error(updateErr)) } cancel2() } } } } // finalizeBootMessage stops the polling goroutine and updates the boot message with final status. func (m *Manager) finalizeBootMessage(msg *models.Message, stopCh chan struct{}, client *agentctl.Client, status string) { if msg == nil || m.bootMessageService == nil { return } // Stop the polling goroutine if stopCh != nil { close(stopCh) } // Final stderr fetch if client != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) lines, err := client.GetAgentStderr(ctx) cancel() if err == nil && len(lines) > 0 { msg.Content = strings.Join(lines, "\n") } } msg.Metadata["status"] = status msg.Metadata["completed_at"] = time.Now().UTC().Format(time.RFC3339Nano) if status == "exited" { msg.Metadata["exit_code"] = 0 } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if updateErr := m.bootMessageService.UpdateMessage(ctx, msg); updateErr != nil { m.logger.Warn("failed to update boot message with final status", zap.String("message_id", msg.ID), zap.Error(updateErr)) } } // buildEnvForRuntime builds environment variables for any runtime. // This is the unified method used by the runtime interface. func (m *Manager) buildEnvForRuntime(executionID string, req *LaunchRequest, agentConfig agents.Agent) map[string]string { env := make(map[string]string) // Copy request environment for k, v := range req.Env { env[k] = v } // Add standard variables for recovery after backend restart env["KANDEV_INSTANCE_ID"] = executionID env["KANDEV_TASK_ID"] = req.TaskID env["KANDEV_SESSION_ID"] = req.SessionID env["KANDEV_AGENT_PROFILE_ID"] = req.AgentProfileID env["TASK_DESCRIPTION"] = req.TaskDescription // Add required credentials from agent config if m.credsMgr != nil && agentConfig != nil { ctx := context.Background() for _, credKey := range agentConfig.Runtime().RequiredEnv { if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { env[credKey] = value } } } return env } // getOrCreateWorktree creates a new worktree or reuses an existing one for session resumption. // If worktree_id is in metadata, it tries to reuse that specific worktree. // Otherwise, creates a new worktree with a unique random suffix. func (m *Manager) getOrCreateWorktree(ctx context.Context, req *LaunchRequest) (*worktree.Worktree, error) { // Check if we have a worktree_id in metadata for session resumption var worktreeID string if req.Metadata != nil { if id, ok := req.Metadata["worktree_id"].(string); ok && id != "" { worktreeID = id } } // Create request with optional WorktreeID for resumption createReq := worktree.CreateRequest{ TaskID: req.TaskID, SessionID: req.SessionID, TaskTitle: req.TaskTitle, RepositoryID: req.RepositoryID, RepositoryPath: req.RepositoryPath, BaseBranch: req.BaseBranch, WorktreeBranchPrefix: req.WorktreeBranchPrefix, PullBeforeWorktree: req.PullBeforeWorktree, WorktreeID: worktreeID, // If set, will try to reuse this worktree } wt, err := m.worktreeMgr.Create(ctx, createReq) if err != nil { return nil, fmt.Errorf("failed to create worktree: %w", err) } if worktreeID != "" && wt.ID == worktreeID { m.logger.Debug("reusing existing worktree for session resumption", zap.String("task_id", req.TaskID), zap.String("worktree_id", wt.ID), zap.String("worktree_path", wt.Path), zap.String("branch", wt.Branch)) } else { m.logger.Info("created new worktree for task", zap.String("task_id", req.TaskID), zap.String("worktree_id", wt.ID), zap.String("worktree_path", wt.Path), zap.String("branch", wt.Branch)) } return wt, nil } // waitForAgentctlReady waits for the agentctl HTTP server to be ready. // This enables shell/workspace features without starting the agent process. func (m *Manager) waitForAgentctlReady(execution *AgentExecution) { opStart := time.Now() // Use detached context that respects stopCh for graceful shutdown ctx, cancel := appctx.Detached(context.Background(), m.stopCh, 60*time.Second) defer cancel() m.logger.Debug("waiting for agentctl to be ready", zap.String("execution_id", execution.ID), zap.String("url", execution.agentctl.BaseURL())) if err := execution.agentctl.WaitForReady(ctx, 60*time.Second); err != nil { m.logger.Error("agentctl not ready", zap.String("execution_id", execution.ID), zap.Duration("duration", time.Since(opStart)), zap.Error(err)) m.updateExecutionError(execution.ID, "agentctl not ready: "+err.Error()) // Use the timeout context for event publishing instead of a fresh Background context m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlError, execution, err.Error()) return } elapsed := time.Since(opStart) if elapsed > 10*time.Second { m.logger.Warn("agentctl ready took longer than expected", zap.String("execution_id", execution.ID), zap.Duration("duration", elapsed)) } else { m.logger.Debug("agentctl ready - shell/workspace access available", zap.String("execution_id", execution.ID), zap.Duration("duration", elapsed)) } // Use the timeout context for event publishing instead of a fresh Background context m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "") } // getAgentConfigForExecution retrieves the agent configuration for an execution. // The execution must have AgentCommand set (which includes the agent type). func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (agents.Agent, error) { if execution.AgentProfileID == "" { return nil, fmt.Errorf("execution %s has no agent profile ID", execution.ID) } if m.profileResolver == nil { return nil, fmt.Errorf("profile resolver not configured") } profileInfo, err := m.profileResolver.ResolveProfile(context.Background(), execution.AgentProfileID) if err != nil { return nil, fmt.Errorf("failed to resolve profile: %w", err) } agentTypeName := profileInfo.AgentName agentConfig, ok := m.registry.Get(agentTypeName) if !ok { return nil, fmt.Errorf("agent type not found: %s", agentTypeName) } return agentConfig, nil } // ResolveAgentProfile resolves an agent profile ID to profile information. // This is exposed for external callers (like the orchestrator executor) to get profile info. // The profile's model is guaranteed to be non-empty as it's validated at creation time. func (m *Manager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { if m.profileResolver == nil { return nil, fmt.Errorf("profile resolver not configured") } return m.profileResolver.ResolveProfile(ctx, profileID) } // resolveMcpServers centralizes MCP resolution for a session: // - loads per-agent MCP config, // - applies executor-scoped transport rules, allow/deny lists, URL rewrites, and env injection, // - converts to ACP stdio server definitions used during session initialization. func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) { if execution == nil { return nil, nil } return m.resolveMcpServersWithParams(ctx, execution.AgentProfileID, execution.Metadata, agentConfig) } // resolveMcpServersWithParams resolves MCP servers with explicit parameters. // This is used by Launch() before the execution object exists. func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID string, metadata map[string]interface{}, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) { if m.mcpProvider == nil || agentConfig == nil { return nil, nil } profileID = strings.TrimSpace(profileID) if profileID == "" { return nil, nil } config, err := m.mcpProvider.GetConfigByProfileID(ctx, profileID) if err != nil { if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) || errors.Is(err, mcpconfig.ErrAgentProfileNotFound) { return nil, nil } m.logger.Warn("failed to load MCP config", zap.String("profile_id", profileID), zap.Error(err)) return nil, err } if config == nil || !config.Enabled { return nil, nil } // Get default runtime for MCP policy (used before execution exists) defaultRT, _ := m.getDefaultRuntime() policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(defaultRT)) executorID := "" if metadata != nil { if value, ok := metadata["executor_id"].(string); ok { executorID = value } if value, ok := metadata["executor_mcp_policy"]; ok { updated, policyWarnings, err := mcpconfig.ApplyExecutorPolicy(policy, value) if err != nil { return nil, fmt.Errorf("invalid executor MCP policy: %w", err) } policy = updated for _, warning := range policyWarnings { m.logger.Warn("mcp policy warning", zap.String("profile_id", profileID), zap.String("executor_id", executorID), zap.String("warning", warning)) } } } resolved, warnings, err := mcpconfig.Resolve(config, policy) if err != nil { return nil, err } for _, warning := range warnings { m.logger.Warn("mcp config warning", zap.String("profile_id", profileID), zap.String("executor_id", executorID), zap.String("warning", warning)) } return mcpconfig.ToACPServers(resolved), nil } func runtimeName(rt Runtime) runtime.Name { if rt == nil { return runtime.NameUnknown } return rt.Name() } // initializeACPSession delegates to SessionManager for full ACP session initialization and prompting func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, mcpServers []agentctltypes.McpServer) error { return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, mcpServers, m.MarkReady) } // handleAgentEvent processes incoming agent events from the agent func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.AgentEvent) { // Update last activity timestamp for stall detection execution.lastActivityAtMu.Lock() execution.lastActivityAt = time.Now() execution.lastActivityAtMu.Unlock() // Log all incoming events for debugging m.logger.Debug("handleAgentEvent entry", zap.String("execution_id", execution.ID), zap.String("event_type", event.Type), zap.String("operation_id", event.OperationID), zap.Int("text_length", len(event.Text))) // Handle different event types based on the Type field switch event.Type { case "message_chunk": // Accumulate message content and stream on newline boundaries for real-time feedback if event.Text != "" { execution.messageMu.Lock() execution.messageBuffer.WriteString(event.Text) bufferLenAfterWrite := execution.messageBuffer.Len() m.logger.Debug("message_chunk written to buffer", zap.String("execution_id", execution.ID), zap.String("operation_id", event.OperationID), zap.Int("text_length", len(event.Text)), zap.Int("buffer_length_after", bufferLenAfterWrite)) // Check if buffer contains newlines - if so, flush up to the last newline bufContent := execution.messageBuffer.String() lastNewline := strings.LastIndex(bufContent, "\n") if lastNewline != -1 { // Extract content up to and including the last newline toFlush := bufContent[:lastNewline+1] remainder := bufContent[lastNewline+1:] // Reset buffer with remainder execution.messageBuffer.Reset() execution.messageBuffer.WriteString(remainder) execution.messageMu.Unlock() // Publish streaming message event if there's content to flush if strings.TrimSpace(toFlush) != "" { m.publishStreamingMessage(execution, toFlush) } } else { execution.messageMu.Unlock() } } // Return early - message_chunk is transformed to message_streaming, no need to publish raw event return case "reasoning": // Stream thinking content like message chunks for real-time feedback // All adapters normalize reasoning to ReasoningText field if event.ReasoningText != "" { execution.messageMu.Lock() execution.thinkingBuffer.WriteString(event.ReasoningText) // Check if buffer contains newlines - if so, flush up to the last newline bufContent := execution.thinkingBuffer.String() lastNewline := strings.LastIndex(bufContent, "\n") if lastNewline != -1 { // Extract content up to and including the last newline toFlush := bufContent[:lastNewline+1] remainder := bufContent[lastNewline+1:] // Reset buffer with remainder execution.thinkingBuffer.Reset() execution.thinkingBuffer.WriteString(remainder) execution.messageMu.Unlock() // Publish streaming thinking event if there's content to flush if strings.TrimSpace(toFlush) != "" { m.publishStreamingThinking(execution, toFlush) } } else { execution.messageMu.Unlock() } } // Return early - reasoning is transformed to thinking_streaming, no need to publish raw event return case "tool_call": // Tool call starting marks a step boundary - flush the accumulated message // This way, each agent response before a tool use becomes a separate comment // Include the flushed text in the event for the orchestrator to save if flushedText := m.flushMessageBuffer(execution); flushedText != "" { event.Text = flushedText // Store flushed agent message to session history for context injection if m.historyManager != nil && execution.SessionID != "" { if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { m.logger.Warn("failed to store agent message to history", zap.Error(err)) } } } // Store tool call to session history for context injection if m.historyManager != nil && execution.SessionID != "" { if err := m.historyManager.AppendToolCall(execution.SessionID, event); err != nil { m.logger.Warn("failed to store tool call to history", zap.Error(err)) } } m.logger.Debug("tool call started", zap.String("execution_id", execution.ID), zap.String("tool_call_id", event.ToolCallID), zap.String("tool_name", event.ToolName)) // Tool call message creation is handled by orchestrator via AgentStreamEvent case "tool_update": // Store tool result to session history when complete if m.historyManager != nil && execution.SessionID != "" && event.ToolStatus == "complete" { if err := m.historyManager.AppendToolResult(execution.SessionID, event); err != nil { m.logger.Warn("failed to store tool result to history", zap.Error(err)) } } // Tool update handled by orchestrator via AgentStreamEvent case "plan": m.logger.Debug("agent plan update", zap.String("execution_id", execution.ID)) case "error": // Flush any accumulated content and clear streaming state on error m.flushMessageBuffer(execution) // Log all available error information for debugging m.logger.Error("agent error", zap.String("execution_id", execution.ID), zap.String("error", event.Error), zap.String("text", event.Text), zap.Any("data", event.Data)) case "complete": // Check buffer content BEFORE any processing execution.messageMu.Lock() bufferContentBeforeFlush := execution.messageBuffer.String() currentMsgID := execution.currentMessageID execution.messageMu.Unlock() // Log preview of buffer content for debugging (first 100 chars) bufferPreview := bufferContentBeforeFlush if len(bufferPreview) > 100 { bufferPreview = bufferPreview[:100] + "..." } // Check if this is an error completion (agent failed to process the prompt) isError := false if event.Data != nil { if v, ok := event.Data["is_error"].(bool); ok { isError = v } } m.logger.Info("agent turn complete", zap.String("execution_id", execution.ID), zap.String("operation_id", event.OperationID), zap.String("session_id", event.SessionID), zap.String("current_msg_id", currentMsgID), zap.Int("buffer_length", len(bufferContentBeforeFlush)), zap.String("buffer_preview", bufferPreview), zap.Bool("is_error", isError)) // Flush the message buffer to publish any remaining content as a streaming message. // If the buffer never triggered streaming (no newlines), we emit the text on the // complete event so the orchestrator can persist the final message. flushedText := m.flushMessageBuffer(execution) if flushedText != "" { event.Text = flushedText if m.historyManager != nil && execution.SessionID != "" { if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { m.logger.Warn("failed to store final agent message to history", zap.Error(err)) } } } m.logger.Info("complete event processed", zap.String("execution_id", execution.ID), zap.String("operation_id", event.OperationID)) // If this was an error completion, mark the execution as failed // The agent process will likely exit soon after sending an error result if isError { errorMsg := "agent error completion" if event.Error != "" { errorMsg = event.Error } m.logger.Warn("error completion received, marking execution as failed", zap.String("execution_id", execution.ID), zap.String("task_id", execution.TaskID), zap.String("error", errorMsg), zap.String("event_error", event.Error), zap.String("event_text", event.Text), zap.Any("event_data", event.Data), zap.String("agent_command", execution.AgentCommand), zap.String("acp_session_id", execution.ACPSessionID)) if err := m.MarkCompleted(execution.ID, 1, errorMsg); err != nil { m.logger.Error("failed to mark execution as failed after error completion", zap.String("execution_id", execution.ID), zap.Error(err)) } // Remove the execution from the store so a new one can be created // The AgentFailed event has already been published by MarkCompleted m.RemoveExecution(execution.ID) } else { // Mark agent as READY for follow-up prompts if err := m.MarkReady(execution.ID); err != nil { m.logger.Error("failed to mark execution as ready after complete", zap.String("execution_id", execution.ID), zap.Error(err)) } } // Signal the completion channel so SendPrompt unblocks stopReason := "end_turn" errorMsg := "" if isError { stopReason = "error" errorMsg = "agent error completion" if event.Error != "" { errorMsg = event.Error } } select { case execution.promptDoneCh <- PromptCompletionSignal{ StopReason: stopReason, IsError: isError, Error: errorMsg, }: default: // Channel full or no one waiting — that's fine (e.g., initial prompt in goroutine) } case "permission_request": m.logger.Debug("permission request received", zap.String("execution_id", execution.ID), zap.String("pending_id", event.PendingID), zap.String("title", event.PermissionTitle)) // Publish permission request to dedicated subject for orchestrator to handle m.eventPublisher.PublishPermissionRequest(execution, event) // Return early - permission requests don't need to be published as stream events return case "context_window": m.logger.Debug("context window update received", zap.String("execution_id", execution.ID), zap.Int64("size", event.ContextWindowSize), zap.Int64("used", event.ContextWindowUsed), zap.Float64("efficiency", event.ContextEfficiency)) // Publish context window event to event bus for orchestrator to persist m.eventPublisher.PublishContextWindow( execution, event.ContextWindowSize, event.ContextWindowUsed, event.ContextWindowRemaining, event.ContextEfficiency, ) // Return early - context window events don't need to be published as stream events return case "available_commands": // Store available commands in the execution for later retrieval (e.g., after page refresh) if len(event.AvailableCommands) > 0 { execution.SetAvailableCommands(event.AvailableCommands) m.logger.Debug("stored available commands", zap.String("execution_id", execution.ID), zap.String("session_id", execution.SessionID), zap.Int("command_count", len(event.AvailableCommands))) // Also publish to event bus for WebSocket broadcast m.eventPublisher.PublishAvailableCommands(execution, event.AvailableCommands) } // Return early - we handle broadcasting ourselves return } // Publish agent stream event to event bus for WebSocket streaming m.eventPublisher.PublishAgentStreamEvent(execution, event) } // handleGitStatusUpdate processes git status updates from the workspace tracker func (m *Manager) handleGitStatusUpdate(execution *AgentExecution, update *agentctl.GitStatusUpdate) { // Publish git status update to event bus for WebSocket streaming and persistence m.eventPublisher.PublishGitStatus(execution, update) } // handleGitCommitCreated processes git commit events from the workspace tracker func (m *Manager) handleGitCommitCreated(execution *AgentExecution, commit *agentctl.GitCommitNotification) { // Publish commit event to event bus for WebSocket streaming and orchestrator handling m.eventPublisher.PublishGitCommit(execution, commit) } // handleGitResetDetected processes git reset events from the workspace tracker func (m *Manager) handleGitResetDetected(execution *AgentExecution, reset *agentctl.GitResetNotification) { // Publish reset event to event bus for orchestrator handling (commit sync) m.eventPublisher.PublishGitReset(execution, reset) } // handleFileChangeNotification processes file change notifications from the workspace tracker func (m *Manager) handleFileChangeNotification(execution *AgentExecution, notification *agentctl.FileChangeNotification) { m.eventPublisher.PublishFileChange(execution, notification) } // handleShellOutput processes shell output from the workspace stream func (m *Manager) handleShellOutput(execution *AgentExecution, data string) { m.eventPublisher.PublishShellOutput(execution, data) } // handleProcessOutput processes script process output from the workspace stream func (m *Manager) handleProcessOutput(execution *AgentExecution, output *agentctl.ProcessOutput) { if output == nil { return } m.logger.Debug("lifecycle received process output", zap.String("session_id", output.SessionID), zap.String("process_id", output.ProcessID), zap.String("kind", string(output.Kind)), zap.String("stream", output.Stream), zap.Int("bytes", len(output.Data)), ) m.eventPublisher.PublishProcessOutput(execution, output) } // handleProcessStatus processes script process status updates from the workspace stream func (m *Manager) handleProcessStatus(execution *AgentExecution, status *agentctl.ProcessStatusUpdate) { if status == nil { return } m.logger.Debug("lifecycle received process status", zap.String("session_id", status.SessionID), zap.String("process_id", status.ProcessID), zap.String("status", string(status.Status)), ) m.eventPublisher.PublishProcessStatus(execution, status) } // handleShellExit processes shell exit events from the workspace stream func (m *Manager) handleShellExit(execution *AgentExecution, code int) { m.eventPublisher.PublishShellExit(execution, code) } // publishStreamingMessage publishes a streaming message event for real-time text updates. // It creates a new message on first call (currentMessageID empty) or appends to existing. // The message ID is generated and set synchronously to avoid race conditions. func (m *Manager) publishStreamingMessage(execution *AgentExecution, content string) { execution.messageMu.Lock() isAppend := execution.currentMessageID != "" messageID := execution.currentMessageID // If this is the first chunk of a new message segment, generate the ID now if !isAppend { messageID = uuid.New().String() execution.currentMessageID = messageID } execution.messageMu.Unlock() event := AgentStreamEventData{ Type: "message_streaming", Text: content, MessageID: messageID, IsAppend: isAppend, } // Create payload manually to include streaming-specific fields payload := &AgentStreamEventPayload{ Type: "agent/event", Timestamp: time.Now().UTC().Format(time.RFC3339Nano), AgentID: execution.ID, TaskID: execution.TaskID, SessionID: execution.SessionID, Data: &event, } m.logger.Debug("publishing streaming message", zap.String("execution_id", execution.ID), zap.String("message_id", messageID), zap.Bool("is_append", isAppend), zap.Int("content_length", len(content))) // Publish the streaming event - orchestrator will handle create/append logic m.eventPublisher.PublishAgentStreamEventPayload(payload) } // flushMessageBuffer extracts any accumulated message from the buffer and returns it. // This is called when a tool use starts or on complete to get the agent's response. // It also clears the currentMessageID to start fresh for the next message segment. // Additionally flushes any accumulated thinking content. func (m *Manager) flushMessageBuffer(execution *AgentExecution) string { execution.messageMu.Lock() agentMessage := execution.messageBuffer.String() thinkingContent := execution.thinkingBuffer.String() execution.messageBuffer.Reset() execution.thinkingBuffer.Reset() // Clear the streaming message IDs so next segment starts fresh currentMsgID := execution.currentMessageID currentThinkingID := execution.currentThinkingID execution.currentMessageID = "" execution.currentThinkingID = "" execution.messageMu.Unlock() // If we have remaining thinking content, publish it trimmedThinking := strings.TrimSpace(thinkingContent) if trimmedThinking != "" { if currentThinkingID != "" { // Append to existing streaming thinking message m.publishStreamingThinkingFinal(execution, currentThinkingID, trimmedThinking) } else { // No streaming thinking message exists yet - create one with all the content // This happens when thinking content has no newlines (never triggered streaming) m.publishStreamingThinking(execution, trimmedThinking) } } // If we have remaining message content, publish it trimmedMessage := strings.TrimSpace(agentMessage) if trimmedMessage != "" { if currentMsgID != "" { // Publish final append to the streaming message m.publishStreamingMessageFinal(execution, currentMsgID, trimmedMessage) } else { // No streaming message exists yet - create one with all the content // This happens when message content has no newlines (never triggered streaming) m.publishStreamingMessage(execution, trimmedMessage) } // Return empty since we've already handled it via streaming return "" } return "" } // publishStreamingMessageFinal publishes the final chunk of a streaming message. // This is called during flush to append any remaining buffered content. func (m *Manager) publishStreamingMessageFinal(execution *AgentExecution, messageID, content string) { event := AgentStreamEventData{ Type: "message_streaming", Text: content, MessageID: messageID, IsAppend: true, } payload := &AgentStreamEventPayload{ Type: "agent/event", Timestamp: time.Now().UTC().Format(time.RFC3339Nano), AgentID: execution.ID, TaskID: execution.TaskID, SessionID: execution.SessionID, Data: &event, } m.logger.Debug("publishing final streaming message chunk", zap.String("execution_id", execution.ID), zap.String("message_id", messageID), zap.Int("content_length", len(content))) m.eventPublisher.PublishAgentStreamEventPayload(payload) } // publishStreamingThinking publishes a streaming thinking event for real-time thinking updates. // It creates a new thinking message on first call (currentThinkingID empty) or appends to existing. // The message ID is generated and set synchronously to avoid race conditions. func (m *Manager) publishStreamingThinking(execution *AgentExecution, content string) { execution.messageMu.Lock() isAppend := execution.currentThinkingID != "" thinkingID := execution.currentThinkingID // If this is the first chunk of a new thinking segment, generate the ID now if !isAppend { thinkingID = uuid.New().String() execution.currentThinkingID = thinkingID } execution.messageMu.Unlock() event := AgentStreamEventData{ Type: "thinking_streaming", Text: content, MessageID: thinkingID, IsAppend: isAppend, MessageType: "thinking", } // Create payload manually to include streaming-specific fields payload := &AgentStreamEventPayload{ Type: "agent/event", Timestamp: time.Now().UTC().Format(time.RFC3339Nano), AgentID: execution.ID, TaskID: execution.TaskID, SessionID: execution.SessionID, Data: &event, } // Publish the streaming event - orchestrator will handle create/append logic m.eventPublisher.PublishAgentStreamEventPayload(payload) } // publishStreamingThinkingFinal publishes the final chunk of a streaming thinking message. // This is called during flush to append any remaining buffered thinking content. func (m *Manager) publishStreamingThinkingFinal(execution *AgentExecution, thinkingID, content string) { event := AgentStreamEventData{ Type: "thinking_streaming", Text: content, MessageID: thinkingID, IsAppend: true, MessageType: "thinking", } payload := &AgentStreamEventPayload{ Type: "agent/event", Timestamp: time.Now().UTC().Format(time.RFC3339Nano), AgentID: execution.ID, TaskID: execution.TaskID, SessionID: execution.SessionID, Data: &event, } m.logger.Debug("publishing final streaming thinking chunk", zap.String("execution_id", execution.ID), zap.String("thinking_id", thinkingID), zap.Int("content_length", len(content))) m.eventPublisher.PublishAgentStreamEventPayload(payload) } // updateExecutionError updates an execution with an error func (m *Manager) updateExecutionError(executionID, errorMsg string) { m.executionStore.UpdateError(executionID, errorMsg) } // PromptAgent sends a follow-up prompt to a running agent // Attachments (images) are passed to the agent if provided func (m *Manager) PromptAgent(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) { execution, exists := m.executionStore.Get(executionID) if !exists { return nil, fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound) } return m.sessionManager.SendPrompt(ctx, execution, prompt, true, attachments) } // CancelAgent interrupts the current agent turn without terminating the process, // allowing the user to send a new prompt. func (m *Manager) CancelAgent(ctx context.Context, executionID string) error { execution, exists := m.executionStore.Get(executionID) if !exists { return fmt.Errorf("execution %q not found", executionID) } if execution.agentctl == nil { return fmt.Errorf("execution %q has no agentctl client", executionID) } m.logger.Info("cancelling agent turn", zap.String("execution_id", executionID), zap.String("task_id", execution.TaskID), zap.String("session_id", execution.SessionID)) if err := execution.agentctl.Cancel(ctx); err != nil { m.logger.Error("failed to cancel agent turn", zap.String("execution_id", executionID), zap.Error(err)) return fmt.Errorf("failed to cancel agent: %w", err) } // Clear streaming state after cancel to ensure clean state for next prompt execution.messageMu.Lock() execution.messageBuffer.Reset() execution.thinkingBuffer.Reset() execution.currentMessageID = "" execution.currentThinkingID = "" execution.messageMu.Unlock() // Mark as ready for follow-up prompts after successful cancel if err := m.MarkReady(executionID); err != nil { m.logger.Warn("failed to mark execution as ready after cancel", zap.String("execution_id", executionID), zap.Error(err)) } m.logger.Info("agent turn cancelled successfully", zap.String("execution_id", executionID)) return nil } // CancelAgentBySessionID cancels the current agent turn for a specific session func (m *Manager) CancelAgentBySessionID(ctx context.Context, sessionID string) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return fmt.Errorf("no agent running for session %q", sessionID) } return m.CancelAgent(ctx, execution.ID) } // StopAgent stops an agent execution func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool) error { execution, exists := m.executionStore.Get(executionID) if !exists { return fmt.Errorf("execution %q not found", executionID) } m.logger.Info("stopping agent", zap.String("execution_id", executionID), zap.Bool("force", force), zap.String("runtime", execution.RuntimeName)) // Try to gracefully stop via agentctl first if execution.agentctl != nil && !force { if err := execution.agentctl.Stop(ctx); err != nil { m.logger.Warn("failed to stop agent via agentctl", zap.String("execution_id", executionID), zap.Error(err)) } execution.agentctl.Close() } // Stop the agent execution via the runtime that created it if execution.RuntimeName != "" && m.runtimeRegistry != nil { rt, err := m.runtimeRegistry.GetRuntime(runtime.Name(execution.RuntimeName)) if err != nil { m.logger.Warn("failed to get runtime for stopping execution", zap.String("execution_id", executionID), zap.String("runtime", execution.RuntimeName), zap.Error(err)) } else { // Stop passthrough process if in passthrough mode if execution.PassthroughProcessID != "" { if interactiveRunner := rt.GetInteractiveRunner(); interactiveRunner != nil { if err := interactiveRunner.Stop(ctx, execution.PassthroughProcessID); err != nil { m.logger.Warn("failed to stop passthrough process", zap.String("execution_id", executionID), zap.String("process_id", execution.PassthroughProcessID), zap.Error(err)) } else { m.logger.Info("passthrough process stopped", zap.String("execution_id", executionID), zap.String("process_id", execution.PassthroughProcessID)) } } } runtimeInstance := &RuntimeInstance{ InstanceID: execution.ID, TaskID: execution.TaskID, ContainerID: execution.ContainerID, StandaloneInstanceID: execution.standaloneInstanceID, StandalonePort: execution.standalonePort, } if err := rt.StopInstance(ctx, runtimeInstance, force); err != nil { // Log the error but don't return - we still need to clean up the execution store. // This ensures that even if the runtime is unavailable (e.g., process crashed), // the execution is removed from tracking so new executions can be launched. m.logger.Warn("failed to stop runtime instance, continuing with cleanup", zap.String("execution_id", executionID), zap.Error(err)) } } } // Update execution status and remove from tracking _ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) { exec.Status = v1.AgentStatusStopped now := time.Now() exec.FinishedAt = &now }) m.executionStore.Remove(executionID) m.logger.Info("agent stopped and removed from tracking", zap.String("execution_id", executionID), zap.String("task_id", execution.TaskID)) // Publish stopped event m.eventPublisher.PublishAgentEvent(ctx, events.AgentStopped, execution) return nil } // StopBySessionID stops the agent for a specific session func (m *Manager) StopBySessionID(ctx context.Context, sessionID string, force bool) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return fmt.Errorf("no agent running for session %q", sessionID) } return m.StopAgent(ctx, execution.ID, force) } // GetExecution returns an agent execution by ID. // // Returns (execution, true) if found, or (nil, false) if not found. // The returned execution pointer should not be modified directly - use the Manager's // methods to update execution state (MarkReady, MarkCompleted, UpdateStatus). // // Thread-safe: Can be called concurrently from multiple goroutines. func (m *Manager) GetExecution(executionID string) (*AgentExecution, bool) { return m.executionStore.Get(executionID) } // GetExecutionBySessionID returns the agent execution for a session. // // Returns (execution, true) if found, or (nil, false) if not found. // A session can have at most one active execution at a time. If a session exists // but has no active execution, this returns (nil, false). // // Thread-safe: Can be called concurrently from multiple goroutines. func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bool) { return m.executionStore.GetBySessionID(sessionID) } // GetAvailableCommandsForSession returns the available slash commands for a session. // Returns nil if the session doesn't exist or has no commands stored. // // Thread-safe: Can be called concurrently from multiple goroutines. func (m *Manager) GetAvailableCommandsForSession(sessionID string) []streams.AvailableCommand { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return nil } return execution.GetAvailableCommands() } // ListExecutions returns all currently tracked agent executions. // // Returns a snapshot of all executions in memory at the time of call. The returned slice // contains pointers to execution objects that may be modified by other goroutines after // this method returns. Do not modify execution state directly - use Manager methods instead. // // The list includes executions in all states: // - Starting (process launching, agentctl initializing) // - Running (actively processing prompts) // - Ready (waiting for user input) // - Completed/Failed (finished but not yet removed) // // Thread-safe: Can be called concurrently. Returns a new slice on each call. // // Typical usage: Status endpoints, debugging, cleanup loops. func (m *Manager) ListExecutions() []*AgentExecution { return m.executionStore.List() } // IsAgentRunningForSession checks if an agent process is running or starting for a session. // // This probes agentctl's status endpoint to verify the agent process state. Returns true if: // - Agent status is "running" (actively processing prompts) // - Agent status is "starting" (process launched but not yet ready) // // Returns false if: // - No execution exists for this session // - agentctl client is not available // - Status check fails (network/timeout error) // - Agent is in any other state (stopped, failed, etc.) // // Note: The name "IsAgentRunning" is slightly misleading - it includes "starting" state. // Use this to check if an agent subprocess exists for the session, regardless of ready state. func (m *Manager) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { // First check if we have an execution tracked for this session execution, exists := m.GetExecutionBySessionID(sessionID) if !exists { return false } // Probe agentctl status to verify the agent process is running if execution.agentctl == nil { return false } status, err := execution.agentctl.GetStatus(ctx) if err != nil { m.logger.Debug("failed to get agentctl status", zap.String("session_id", sessionID), zap.Error(err)) return false } return status.IsAgentRunning() } // UpdateStatus updates the status of an execution func (m *Manager) UpdateStatus(executionID string, status v1.AgentStatus) error { if err := m.executionStore.WithLock(executionID, func(execution *AgentExecution) { execution.Status = status }); err != nil { if errors.Is(err, ErrExecutionNotFound) { return fmt.Errorf("execution %q not found", executionID) } return err } m.logger.Debug("updated execution status", zap.String("execution_id", executionID), zap.String("status", string(status))) return nil } // MarkReady marks an execution as ready for follow-up prompts. // // This transitions the execution to the "ready" state, indicating the agent has finished // processing the current prompt and is waiting for user input. This is called: // - After agent initialization completes (session loaded, workspace ready) // - After agent finishes processing a prompt (via stream completion event) // - After cancelling an agent turn (to allow new prompts) // // State Machine Transitions: // // Starting -> Ready (after initialization) // Running -> Ready (after prompt completion) // Any -> Ready (after cancel) // // Publishes an AgentReady event to notify subscribers (frontend, orchestrator). // // Returns error if execution not found. func (m *Manager) MarkReady(executionID string) error { execution, exists := m.executionStore.Get(executionID) if !exists { return fmt.Errorf("execution %q not found", executionID) } // Skip if already ready (prevents duplicate events) if execution.Status == v1.AgentStatusReady { return nil } m.executionStore.UpdateStatus(executionID, v1.AgentStatusReady) m.logger.Info("execution ready for follow-up prompts", zap.String("execution_id", executionID)) // Publish ready event m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentReady, execution) return nil } // MarkCompleted marks an execution as completed or failed. // // This is called when the agent process terminates, either successfully or with an error. // The final status is determined by exit code and error message: // // - exitCode == 0 && errorMessage == "" → AgentStatusCompleted (success) // - Otherwise → AgentStatusFailed (failure) // // Parameters: // - executionID: The execution to mark as completed // - exitCode: Process exit code (0 = success, non-zero = failure) // - errorMessage: Human-readable error description (empty string if no error) // // State Machine: // // This is a terminal state transition - no further state changes are expected after this. // Typical flow: Starting -> Running -> Ready -> ... -> Completed/Failed // // Publishes either AgentCompleted or AgentFailed event depending on final status. // // Returns error if execution not found. func (m *Manager) MarkCompleted(executionID string, exitCode int, errorMessage string) error { execution, exists := m.executionStore.Get(executionID) if !exists { return fmt.Errorf("execution %q not found", executionID) } _ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) { now := time.Now() exec.FinishedAt = &now exec.ExitCode = &exitCode exec.ErrorMessage = errorMessage if exitCode == 0 && errorMessage == "" { exec.Status = v1.AgentStatusCompleted } else { exec.Status = v1.AgentStatusFailed } }) m.logger.Info("execution completed", zap.String("execution_id", executionID), zap.Int("exit_code", exitCode), zap.String("status", string(execution.Status))) // Publish completion event eventType := events.AgentCompleted if execution.Status == v1.AgentStatusFailed { eventType = events.AgentFailed } m.eventPublisher.PublishAgentEvent(context.Background(), eventType, execution) return nil } // RemoveExecution removes an execution from tracking. // // ⚠️ WARNING: This is a potentially dangerous operation that should only be called when: // 1. The agent process has been fully stopped (via StopAgent) // 2. All cleanup operations have completed (worktree cleanup, container removal) // 3. The execution is in a terminal state (Completed, Failed, or Cancelled) // // This method: // - Removes the execution from the in-memory store // - Makes the sessionID available for new executions // - Does NOT stop the agent process (call StopAgent first) // - Does NOT close the agentctl client (call execution.agentctl.Close() first) // - Does NOT clean up resources (worktrees, containers, etc.) // // After calling this, the executionID and sessionID can no longer be used to query // or control the execution. Any references to this execution will become invalid. // // Typical usage: Called by cleanup loops or after successful StopAgent completion. // For stale/dead executions, use CleanupStaleExecutionBySessionID instead. func (m *Manager) RemoveExecution(executionID string) { m.executionStore.Remove(executionID) m.logger.Debug("removed execution from tracking", zap.String("execution_id", executionID)) } // CleanupStaleExecutionBySessionID removes a stale execution from tracking without stopping it. // // A "stale" execution is one where the agent process has stopped externally (crashed, killed, // or terminated outside of our control) but the execution is still tracked in memory. // // When to use this: // - After detecting the agentctl HTTP server is unreachable // - When the agent container no longer exists (Docker runtime) // - After server restart when recovering persisted state // - When IsAgentRunningForSession returns false but execution exists // // This method performs cleanup: // 1. Closes the agentctl HTTP client connection // 2. Removes the execution from the in-memory tracking store // // What this does NOT do: // - Stop the agent process (assumed already stopped) // - Clean up worktrees or containers (caller's responsibility) // - Update database session state (caller's responsibility) // // This is safe to call even if the process is still running - it won't send kill signals. // Use StopAgent if you need to actively terminate a running agent. // // Returns nil if no execution exists for the session (idempotent). func (m *Manager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return nil // No execution to clean up } m.logger.Info("cleaning up stale agent execution", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID)) // Close agentctl connection if it exists if execution.agentctl != nil { execution.agentctl.Close() } // Remove from execution store m.executionStore.Remove(execution.ID) return nil } // cleanupLoop runs periodic cleanup of stale containers func (m *Manager) cleanupLoop(ctx context.Context) { defer m.wg.Done() ticker := time.NewTicker(m.cleanupInterval) defer ticker.Stop() for { select { case <-ctx.Done(): m.logger.Info("cleanup loop stopped (context cancelled)") return case <-m.stopCh: m.logger.Info("cleanup loop stopped") return case <-ticker.C: m.performCleanup(ctx) } } } // performCleanup checks for and cleans up stale containers (Docker mode only) func (m *Manager) performCleanup(ctx context.Context) { m.logger.Debug("running cleanup check") // Skip cleanup if container manager is not available if m.containerManager == nil { m.logger.Debug("skipping cleanup - no container manager") return } // List all kandev-managed containers containers, err := m.containerManager.ListManagedContainers(ctx) if err != nil { m.logger.Error("failed to list containers for cleanup", zap.Error(err)) return } for _, container := range containers { // Check if container is exited and we're tracking it if container.State == "exited" { execution, tracked := m.executionStore.GetByContainerID(container.ID) if tracked { // Get container info to get exit code info, err := m.containerManager.GetContainerInfo(ctx, container.ID) if err != nil { m.logger.Warn("failed to get container info during cleanup", zap.String("container_id", container.ID), zap.Error(err)) continue } // Mark execution as completed errorMsg := "" if info.ExitCode != 0 { errorMsg = fmt.Sprintf("container exited with code %d", info.ExitCode) } _ = m.MarkCompleted(execution.ID, info.ExitCode, errorMsg) // Remove the container if err := m.containerManager.RemoveContainer(ctx, container.ID, false); err != nil { m.logger.Warn("failed to remove container during cleanup", zap.String("container_id", container.ID), zap.Error(err)) } // Remove the execution from tracking so new agents can be launched m.RemoveExecution(execution.ID) } } } } // RespondToPermission sends a response to an agent's permission request. // // When an agent requests permission (e.g., to run a bash command, modify files, etc.), // it pauses execution and waits for user approval. This method sends the user's response. // // Parameters: // - executionID: The agent execution waiting for permission // - pendingID: Unique ID of the permission request (from permission request event) // - optionID: The user-selected option ID (from the permission request's options array) // - cancelled: If true, indicates user cancelled/rejected the permission request. // When cancelled=true, optionID is ignored. // // Response Semantics: // - cancelled=false, optionID="approve" → User approved the action // - cancelled=false, optionID="deny" → User explicitly denied the action // - cancelled=true, optionID="" → User cancelled/closed the dialog // // After receiving the response, the agent will either: // - Continue executing (if approved) // - Skip the action and report failure (if denied/cancelled) // // Timeout: 30 seconds for agentctl to acknowledge the response. // // Returns error if execution not found, agentctl unavailable, or communication fails. func (m *Manager) RespondToPermission(executionID, pendingID, optionID string, cancelled bool) error { execution, exists := m.executionStore.Get(executionID) if !exists { return fmt.Errorf("agent execution not found: %s", executionID) } if execution.agentctl == nil { return fmt.Errorf("agent execution has no agentctl client: %s", executionID) } m.logger.Info("responding to permission request", zap.String("execution_id", executionID), zap.String("pending_id", pendingID), zap.String("option_id", optionID), zap.Bool("cancelled", cancelled)) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return execution.agentctl.RespondToPermission(ctx, pendingID, optionID, cancelled) } // RespondToPermissionBySessionID sends a response to a permission request using session ID. // // Convenience method that looks up the execution by session ID and delegates to RespondToPermission. // See RespondToPermission for parameter semantics and behavior. func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID string, cancelled bool) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return fmt.Errorf("no agent execution found for session: %s", sessionID) } return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled) } // MarkPassthroughRunning marks a passthrough execution as running when user submits input. // This is called when Enter key is detected in the terminal handler. // It updates the execution status and publishes an AgentRunning event. func (m *Manager) MarkPassthroughRunning(sessionID string) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return fmt.Errorf("no agent execution found for session: %s", sessionID) } if execution.PassthroughProcessID == "" { return fmt.Errorf("session %s is not in passthrough mode", sessionID) } // Only publish if not already running (prevents duplicate events) if execution.Status != v1.AgentStatusRunning { m.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning) m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentRunning, execution) } return nil } // WritePassthroughStdin writes data to the agent process stdin in passthrough mode. // Returns an error if the session is not in passthrough mode or if writing fails. // Note: For terminal handler input, use MarkPassthroughRunning directly since // the terminal handler writes to PTY directly for performance. func (m *Manager) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return fmt.Errorf("no agent execution found for session: %s", sessionID) } if execution.PassthroughProcessID == "" { return fmt.Errorf("session %s is not in passthrough mode", sessionID) } // Get the interactive runner from runtime interactiveRunner := m.GetInteractiveRunner() if interactiveRunner == nil { return fmt.Errorf("interactive runner not available") } // Write to stdin if err := interactiveRunner.WriteStdin(execution.PassthroughProcessID, data); err != nil { return err } return nil } // IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. func (m *Manager) IsPassthroughSession(ctx context.Context, sessionID string) bool { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return false } return execution.PassthroughProcessID != "" } // ResizePassthroughPTY resizes the PTY for a passthrough process. // Returns an error if the session is not in passthrough mode or if resizing fails. func (m *Manager) ResizePassthroughPTY(ctx context.Context, sessionID string, cols, rows uint16) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return fmt.Errorf("no agent execution found for session: %s", sessionID) } if execution.PassthroughProcessID == "" { return fmt.Errorf("session %s is not in passthrough mode", sessionID) } // Get the interactive runner from runtime interactiveRunner := m.GetInteractiveRunner() if interactiveRunner == nil { return fmt.Errorf("interactive runner not available") } return interactiveRunner.ResizeBySession(sessionID, cols, rows) } // GetPassthroughBuffer returns the buffered output from the passthrough process. // This is used for new subscribers to catch up on output. func (m *Manager) GetPassthroughBuffer(ctx context.Context, sessionID string) (string, error) { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return "", fmt.Errorf("no agent execution found for session: %s", sessionID) } if execution.PassthroughProcessID == "" { return "", fmt.Errorf("session %s is not in passthrough mode", sessionID) } // Get the interactive runner from runtime interactiveRunner := m.GetInteractiveRunner() if interactiveRunner == nil { return "", fmt.Errorf("interactive runner not available") } chunks, ok := interactiveRunner.GetBuffer(execution.PassthroughProcessID) if !ok { return "", fmt.Errorf("passthrough process not found") } // Concatenate all chunks into a single string var buffer strings.Builder for _, chunk := range chunks { buffer.WriteString(chunk.Data) } return buffer.String(), nil } // startPassthroughSession starts an agent in passthrough mode (direct terminal interaction). // Instead of using ACP protocol, the agent's stdin/stdout is passed through directly. func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentExecution, profileInfo *AgentProfileInfo) error { // Get agent config for passthrough command agentConfig, err := m.getAgentConfigForExecution(execution) if err != nil { return fmt.Errorf("failed to get agent config: %w", err) } // Validate passthrough support ptAgent, ok := agentConfig.(agents.PassthroughAgent) if !ok { return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) } pt := ptAgent.PassthroughConfig() rt := agentConfig.Runtime() // Get task description from metadata for initial prompt taskDescription := "" if execution.Metadata != nil { if desc, ok := execution.Metadata["task_description"].(string); ok { taskDescription = desc } } // Build passthrough command with initial prompt and profile settings cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ Model: profileModel(profileInfo), SessionID: execution.ACPSessionID, Prompt: taskDescription, PermissionValues: profilePermissionValues(profileInfo), }) if cmd.IsEmpty() { return fmt.Errorf("passthrough command is empty for agent %s", agentConfig.ID()) } m.logger.Info("passthrough command built", zap.String("session_id", execution.SessionID), zap.Strings("full_command", cmd.Args())) // Get the interactive runner from runtime interactiveRunner := m.GetInteractiveRunner() if interactiveRunner == nil { return fmt.Errorf("interactive runner not available for passthrough mode") } // Build environment variables env := make(map[string]string) env["KANDEV_TASK_ID"] = execution.TaskID env["KANDEV_SESSION_ID"] = execution.SessionID env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID // Add required credentials from agent config if m.credsMgr != nil { for _, credKey := range rt.RequiredEnv { if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { env[credKey] = value } } } // Start the interactive process immediately with default dimensions // Start process - either immediately (default) or wait for terminal connection // Some agents (like Codex) require the terminal to be connected first because // they query the terminal for cursor position on startup startReq := process.InteractiveStartRequest{ SessionID: execution.SessionID, Command: cmd.Args(), WorkingDir: execution.WorkspacePath, Env: env, PromptPattern: pt.PromptPattern, IdleTimeout: pt.IdleTimeout, BufferMaxBytes: pt.BufferMaxBytes, StatusDetector: pt.StatusDetector, CheckInterval: pt.CheckInterval, StabilityWindow: pt.StabilityWindow, ImmediateStart: !pt.WaitForTerminal, DefaultCols: 120, DefaultRows: 40, } processInfo, err := interactiveRunner.Start(ctx, startReq) if err != nil { m.updateExecutionError(execution.ID, "failed to start passthrough session: "+err.Error()) return fmt.Errorf("failed to start passthrough session: %w", err) } // Store the passthrough process ID execution.PassthroughProcessID = processInfo.ID m.logger.Info("passthrough session started", zap.String("execution_id", execution.ID), zap.String("task_id", execution.TaskID), zap.String("session_id", execution.SessionID), zap.String("process_id", processInfo.ID), zap.Strings("command", cmd.Args())) // Emit agentctl ready event to indicate session is available m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "") // Start shell session for workspace shell access. // In passthrough mode, the agent runs via InteractiveRunner, so the agentctl // process manager never starts and the shell isn't auto-created. // We need to start it explicitly so the right panel terminal works. if execution.agentctl != nil { if err := execution.agentctl.StartShell(ctx); err != nil { m.logger.Warn("failed to start shell for passthrough session", zap.String("execution_id", execution.ID), zap.Error(err)) // Non-fatal: continue without shell } else { m.logger.Info("shell session started for passthrough mode", zap.String("execution_id", execution.ID)) } } // Connect to workspace stream for shell/git/file features even in passthrough mode. // The passthrough terminal handles agent interaction (center panel), while the // workspace stream provides shell I/O (right panel) - they work independently. if m.streamManager != nil && execution.agentctl != nil { go m.streamManager.connectWorkspaceStream(execution, nil) } return nil } // profileModel extracts the model from profile info, returning empty string if nil. func profileModel(p *AgentProfileInfo) string { if p == nil { return "" } return p.Model } // profilePermissionValues builds a permission values map from profile info. func profilePermissionValues(p *AgentProfileInfo) map[string]bool { if p == nil { return nil } return map[string]bool{ "auto_approve": p.AutoApprove, "dangerously_skip_permissions": p.DangerouslySkipPermissions, "allow_indexing": p.AllowIndexing, } } // ResumePassthroughSession restarts a passthrough session after backend restart. // This is called when user reconnects to a terminal but the PTY process is no longer running. // If the agent supports resume, it uses the resume flag to continue the last conversation. // Otherwise, it starts a fresh CLI session with the same profile settings. func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string) error { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return fmt.Errorf("no execution found for session: %s", sessionID) } // Get agent config agentConfig, err := m.getAgentConfigForExecution(execution) if err != nil { return fmt.Errorf("failed to get agent config: %w", err) } ptAgent, ok := agentConfig.(agents.PassthroughAgent) if !ok { return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) } pt := ptAgent.PassthroughConfig() rt := agentConfig.Runtime() // Get profile info for permission settings var profileInfo *AgentProfileInfo if m.profileResolver != nil && execution.AgentProfileID != "" { profileInfo, _ = m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) } // Build the resume command cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ Model: profileModel(profileInfo), Resume: true, PermissionValues: profilePermissionValues(profileInfo), }) if cmd.IsEmpty() { return fmt.Errorf("passthrough resume command is empty for agent %s", agentConfig.ID()) } m.logger.Info("resuming passthrough session", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID), zap.Strings("command", cmd.Args())) // Get the interactive runner interactiveRunner := m.GetInteractiveRunner() if interactiveRunner == nil { return fmt.Errorf("interactive runner not available") } // Build environment variables env := make(map[string]string) env["KANDEV_TASK_ID"] = execution.TaskID env["KANDEV_SESSION_ID"] = execution.SessionID env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID // Add required credentials if m.credsMgr != nil { for _, credKey := range rt.RequiredEnv { if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { env[credKey] = value } } } // Start the interactive process. // Always use immediate start on resume — the terminal WebSocket is already connected, // so we don't need to wait for a resize to get exact dimensions. The first resize // from the terminal will correct the dimensions. Without this, TUI apps that use // WaitForTerminal would never start because the frontend may not send resizes // to a process it doesn't know about yet. startReq := process.InteractiveStartRequest{ SessionID: sessionID, Command: cmd.Args(), WorkingDir: execution.WorkspacePath, Env: env, IdleTimeout: pt.IdleTimeout, BufferMaxBytes: pt.BufferMaxBytes, StatusDetector: pt.StatusDetector, CheckInterval: pt.CheckInterval, StabilityWindow: pt.StabilityWindow, ImmediateStart: true, DefaultCols: 120, DefaultRows: 40, } processInfo, err := interactiveRunner.Start(ctx, startReq) if err != nil { return fmt.Errorf("failed to start passthrough session: %w", err) } // Update the execution with new process ID execution.PassthroughProcessID = processInfo.ID m.logger.Info("passthrough session resumed", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID), zap.String("process_id", processInfo.ID)) // Start shell session for workspace shell access (right panel terminal). // This needs to be done after resume since the shell process was killed on backend restart. if execution.agentctl != nil { if err := execution.agentctl.StartShell(ctx); err != nil { m.logger.Warn("failed to start shell for resumed passthrough session", zap.String("execution_id", execution.ID), zap.Error(err)) // Non-fatal: continue without shell } else { m.logger.Info("shell session started for resumed passthrough session", zap.String("execution_id", execution.ID)) } } // Connect to workspace stream for shell/git/file features. // Only connect if not already connected (process restart reuses the same agentctl). if m.streamManager != nil && execution.agentctl != nil && execution.GetWorkspaceStream() == nil { go m.streamManager.connectWorkspaceStream(execution, nil) } return nil } // handlePassthroughTurnComplete is called when turn detection fires for a passthrough session. // This marks the execution as ready for follow-up prompts when the agent finishes processing. func (m *Manager) handlePassthroughTurnComplete(sessionID string) { execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { m.logger.Debug("turn complete for unknown session (may have ended)", zap.String("session_id", sessionID)) return } m.logger.Info("passthrough turn complete", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID)) // Mark execution as ready for follow-up prompts // This publishes AgentReady event to notify subscribers if err := m.MarkReady(execution.ID); err != nil { m.logger.Error("failed to mark execution as ready after passthrough turn complete", zap.String("execution_id", execution.ID), zap.Error(err)) } } // handlePassthroughOutput handles output from a passthrough process and publishes it to the event bus. // This is called when running in standalone mode without a WorkspaceTracker. func (m *Manager) handlePassthroughOutput(output *agentctltypes.ProcessOutput) { if output == nil { return } execution, exists := m.executionStore.GetBySessionID(output.SessionID) if !exists { m.logger.Debug("passthrough output for unknown session", zap.String("session_id", output.SessionID)) return } // Convert to agentctl client type for event publisher clientOutput := &agentctl.ProcessOutput{ SessionID: output.SessionID, ProcessID: output.ProcessID, Kind: streams.ProcessKind(output.Kind), Stream: output.Stream, Data: output.Data, Timestamp: output.Timestamp, } m.eventPublisher.PublishProcessOutput(execution, clientOutput) } // handlePassthroughStatus handles status updates from a passthrough process and publishes to the event bus. // This is called when running in standalone mode without a WorkspaceTracker. // When the process exits while a WebSocket is connected, it attempts auto-restart with rate limiting. func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpdate) { if status == nil { return } execution, exists := m.executionStore.GetBySessionID(status.SessionID) if !exists { m.logger.Debug("passthrough status for unknown session", zap.String("session_id", status.SessionID)) return } // Convert to agentctl client type for event publisher clientStatus := &agentctl.ProcessStatusUpdate{ SessionID: status.SessionID, ProcessID: status.ProcessID, Kind: streams.ProcessKind(status.Kind), Command: status.Command, ScriptName: status.ScriptName, WorkingDir: status.WorkingDir, Status: streams.ProcessStatus(status.Status), ExitCode: status.ExitCode, Timestamp: status.Timestamp, } m.eventPublisher.PublishProcessStatus(execution, clientStatus) // Check if process exited and should be auto-restarted // Only restart if this is the ACTUAL passthrough process, not user shell terminals // Run asynchronously to allow the old process to be cleaned up first if status.Status == agentctltypes.ProcessStatusExited || status.Status == agentctltypes.ProcessStatusFailed { // Only trigger auto-restart for the passthrough process, not for user shell terminals if execution.PassthroughProcessID != "" && status.ProcessID == execution.PassthroughProcessID { go m.handlePassthroughExit(execution, status) } else { m.logger.Debug("process exited but not the passthrough process, skipping auto-restart", zap.String("session_id", status.SessionID), zap.String("exited_process_id", status.ProcessID), zap.String("passthrough_process_id", execution.PassthroughProcessID)) } } } // handlePassthroughExit handles auto-restart logic when a passthrough process exits. // This function is called asynchronously to allow the old process to be cleaned up first. func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agentctltypes.ProcessStatusUpdate) { const restartDelay = 500 * time.Millisecond const cleanupDelay = 100 * time.Millisecond // Wait for old process cleanup // Wait a bit for the old process to be cleaned up from the process map time.Sleep(cleanupDelay) sessionID := execution.SessionID interactiveRunner := m.GetInteractiveRunner() if interactiveRunner == nil { m.logger.Debug("no interactive runner available for auto-restart", zap.String("session_id", sessionID)) return } // Check if WebSocket is still connected (use session-level tracking which survives process deletion) if !interactiveRunner.HasActiveWebSocketBySession(sessionID) { m.logger.Debug("no active WebSocket, skipping auto-restart", zap.String("session_id", sessionID)) return } exitCode := 0 if status.ExitCode != nil { exitCode = *status.ExitCode } m.logger.Info("passthrough process exited with active WebSocket, attempting auto-restart", zap.String("session_id", sessionID), zap.Int("exit_code", exitCode)) // Send restart notification to terminal (use session-level to survive process deletion) restartMsg := "\r\n\x1b[33m[Agent exited. Restarting...]\x1b[0m\r\n" if err := interactiveRunner.WriteToDirectOutputBySession(sessionID, []byte(restartMsg)); err != nil { m.logger.Debug("failed to write restart message to terminal", zap.String("session_id", sessionID), zap.Error(err)) } // Delay before restart time.Sleep(restartDelay) // Check WebSocket is still connected after delay (use session-level tracking) if !interactiveRunner.HasActiveWebSocketBySession(sessionID) { m.logger.Debug("WebSocket disconnected during restart delay, aborting", zap.String("session_id", sessionID)) return } // Attempt restart ctx := context.Background() if err := m.ResumePassthroughSession(ctx, sessionID); err != nil { m.logger.Error("failed to auto-restart passthrough session", zap.String("session_id", sessionID), zap.Error(err)) // Send error message to terminal errorMsg := fmt.Sprintf("\r\n\x1b[31m[Restart failed: %s]\x1b[0m\r\n", err.Error()) if writeErr := interactiveRunner.WriteToDirectOutputBySession(sessionID, []byte(errorMsg)); writeErr != nil { m.logger.Debug("failed to write restart error message to terminal", zap.String("session_id", sessionID), zap.Error(writeErr)) } return } // Connect the session's existing WebSocket to the new process if interactiveRunner.ConnectSessionWebSocket(execution.PassthroughProcessID) { m.logger.Info("passthrough session auto-restarted and reconnected WebSocket", zap.String("session_id", sessionID), zap.String("new_process_id", execution.PassthroughProcessID)) } else { m.logger.Warn("passthrough session restarted but failed to reconnect WebSocket", zap.String("session_id", sessionID), zap.String("new_process_id", execution.PassthroughProcessID)) } } // GetInteractiveRunner returns the interactive runner for passthrough mode. // Returns nil if the runtime is not available or doesn't support passthrough. func (m *Manager) GetInteractiveRunner() *process.InteractiveRunner { if m.runtimeRegistry == nil { return nil } standaloneRT, err := m.runtimeRegistry.GetRuntime(runtime.NameStandalone) if err != nil { return nil } return standaloneRT.GetInteractiveRunner() }