3011 lines
108 KiB
Go
3011 lines
108 KiB
Go
// 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()
|
|
}
|