Files
kandev/apps/backend/internal/agent/lifecycle/manager.go
T
João Salavisa 82e8c8905b feat: replace agent_type with agent_profile_id (#14)
* feat: replace agent_type with agent_profile_id

- Replace agent_type field with agent_profile_id across backend and frontend
- Add ProfileResolver interface and StoreProfileResolver implementation
- Update task creation/update to use agent_profile_id
- Change default agent runtime from docker to standalone
- Fix workspace state types to include all required fields
- Fix websocket handlers type mismatches for environments, executors, workspaces
- Update integration tests to use agent_profile_id

* feat: pass model as CLI argument to agents

- Add ModelFlag field to AgentTypeConfig for specifying the CLI flag
- Set ModelFlag to '--model' for all agents (auggie, claude, codex, gemini, opencode, copilot)
- Update launchStandalone to append model flag to agent command
- Update launchDocker/buildContainerConfig to append model flag to container command
- Model is passed when both agent supports ModelFlag and profile has Model configured

* fix: make WebSocket message handling async to prevent blocking

When a long-running handler like orchestrator.prompt was executing,
it blocked the ReadPump loop, preventing other requests (like
workspace.tree.get) from being processed until the handler completed.

Now each message is handled in its own goroutine, allowing concurrent
message handling so file tree requests work while the agent is working.

* feat: show task description as first message in chat

- Add 'Task' label with distinct styling (lighter background, border)
- Pass taskDescription from page to TaskChatPanel component
- Differentiate from regular user messages visually
2026-01-14 12:29:58 +00:00

2146 lines
67 KiB
Go

// Package lifecycle manages agent instance lifecycles including tracking,
// state transitions, and cleanup.
package lifecycle
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
acp "github.com/coder/acp-go-sdk"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agentctl"
"github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/worktree"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// Default agentctl port
const AgentCtlPort = 9999
// AgentInstance represents a running agent instance
type AgentInstance struct {
ID string
TaskID string
AgentProfileID string
ContainerID string
ContainerIP string // IP address of the container for agentctl communication
WorkspacePath string // Path to the workspace (worktree or repository path)
Status v1.AgentStatus
StartedAt time.Time
FinishedAt *time.Time
ExitCode *int
ErrorMessage string
Progress int
Metadata map[string]interface{}
// agentctl client for this instance
agentctl *agentctl.Client
// Standalone mode info (when not using Docker)
standaloneInstanceID string // Instance ID in standalone agentctl
standalonePort int // Port of the standalone instance
// Message buffer for accumulating agent response during a prompt
messageBuffer strings.Builder
messageMu sync.Mutex
}
// GetAgentCtlClient returns the agentctl client for this instance
func (ai *AgentInstance) GetAgentCtlClient() *agentctl.Client {
return ai.agentctl
}
// LaunchRequest contains parameters for launching an agent
type LaunchRequest struct {
TaskID string
TaskTitle string // Human-readable task title for semantic worktree naming
AgentProfileID string
WorkspacePath string // Host path to workspace (original repository path)
TaskDescription string // Task description to send via ACP prompt
Env map[string]string // Additional env vars
Metadata map[string]interface{}
// Worktree configuration
UseWorktree bool // Whether to use a Git worktree for isolation
RepositoryID string // Repository ID for worktree tracking
RepositoryPath string // Path to the main repository (for worktree creation)
BaseBranch string // Base branch for the worktree (e.g., "main")
}
// CredentialsManager interface for credential retrieval
type CredentialsManager interface {
GetCredentialValue(ctx context.Context, key string) (value string, err error)
}
// AgentProfileInfo contains resolved profile information
type AgentProfileInfo struct {
ProfileID string
ProfileName string
AgentID string
AgentName string // e.g., "auggie", "claude", "codex"
Model string
AutoApprove bool
DangerouslySkipPermissions bool
Plan string
}
// ProfileResolver resolves agent profile IDs to profile information
type ProfileResolver interface {
ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error)
}
// Manager manages agent instance lifecycles
type Manager struct {
docker *docker.Client
registry *registry.Registry
eventBus bus.EventBus
credsMgr CredentialsManager
profileResolver ProfileResolver
worktreeMgr *worktree.Manager
logger *logger.Logger
// Agent runtime configuration
agentCfg config.AgentConfig
standaloneCtl *agentctl.StandaloneCtl // Client for standalone agentctl control API
// Track active instances
instances map[string]*AgentInstance // by instance ID
byTask map[string]string // taskID -> instanceID
byContainer map[string]string // containerID -> instanceID
mu sync.RWMutex
// Background cleanup
cleanupInterval time.Duration
stopCh chan struct{}
wg sync.WaitGroup
}
// NewManager creates a new lifecycle manager
func NewManager(
dockerClient *docker.Client,
reg *registry.Registry,
eventBus bus.EventBus,
agentCfg config.AgentConfig,
log *logger.Logger,
) *Manager {
mgr := &Manager{
docker: dockerClient,
registry: reg,
eventBus: eventBus,
agentCfg: agentCfg,
logger: log.WithFields(zap.String("component", "lifecycle-manager")),
instances: make(map[string]*AgentInstance),
byTask: make(map[string]string),
byContainer: make(map[string]string),
cleanupInterval: 30 * time.Second,
stopCh: make(chan struct{}),
}
// Initialize standalone agentctl client if in standalone mode
if agentCfg.Runtime == "standalone" {
mgr.standaloneCtl = agentctl.NewStandaloneCtl(
agentCfg.StandaloneHost,
agentCfg.StandalonePort,
log,
)
mgr.logger.Info("initialized for standalone mode",
zap.String("host", agentCfg.StandaloneHost),
zap.Int("port", agentCfg.StandalonePort))
}
return mgr
}
// IsStandaloneMode returns true if running in standalone mode
func (m *Manager) IsStandaloneMode() bool {
return m.agentCfg.Runtime == "standalone"
}
// SetCredentialsManager sets the credentials manager for injecting secrets
func (m *Manager) SetCredentialsManager(credsMgr CredentialsManager) {
m.credsMgr = credsMgr
}
// SetProfileResolver sets the profile resolver for looking up agent profiles
func (m *Manager) SetProfileResolver(resolver ProfileResolver) {
m.profileResolver = resolver
}
// SetWorktreeManager sets the worktree manager for Git worktree isolation
func (m *Manager) SetWorktreeManager(worktreeMgr *worktree.Manager) {
m.worktreeMgr = worktreeMgr
}
// Start starts the lifecycle manager background tasks
func (m *Manager) Start(ctx context.Context) error {
m.logger.Info("starting lifecycle manager",
zap.String("runtime", m.agentCfg.Runtime))
if m.IsStandaloneMode() {
// Check if standalone agentctl is reachable
if err := m.standaloneCtl.Health(ctx); err != nil {
m.logger.Warn("standalone agentctl not reachable",
zap.String("host", m.agentCfg.StandaloneHost),
zap.Int("port", m.agentCfg.StandalonePort),
zap.Error(err))
// Continue anyway - it might come up later
} else {
m.logger.Info("standalone agentctl is reachable")
}
} else {
// Reconcile with Docker to find running containers from previous runs
if _, err := m.reconcileWithDocker(ctx); err != nil {
m.logger.Warn("failed to reconcile with Docker", zap.Error(err))
// Continue anyway - this is best-effort
}
}
// Start cleanup loop
m.wg.Add(1)
go m.cleanupLoop(ctx)
return nil
}
// RecoveredInstance contains info about an instance recovered from Docker
type RecoveredInstance struct {
InstanceID string
TaskID string
ContainerID string
AgentProfileID string
}
// reconcileWithDocker finds running kandev containers and re-populates tracking maps
// Returns a list of recovered instances so the caller can sync with the database
func (m *Manager) reconcileWithDocker(ctx context.Context) ([]RecoveredInstance, error) {
// Skip reconciliation if docker client is not available (e.g., in tests)
if m.docker == nil {
m.logger.Debug("skipping Docker reconciliation - no docker client")
return nil, nil
}
m.logger.Info("reconciling with Docker - looking for running containers")
// List all containers with our label
containers, err := m.docker.ListContainers(ctx, map[string]string{
"kandev.managed": "true",
})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
m.mu.Lock()
defer m.mu.Unlock()
var recovered []RecoveredInstance
for _, ctr := range containers {
// Only recover running containers
if ctr.State != "running" {
m.logger.Debug("skipping non-running container",
zap.String("container_id", ctr.ID),
zap.String("state", ctr.State))
continue
}
// Get container details to extract labels
labels, err := m.docker.GetContainerLabels(ctx, ctr.ID)
if err != nil {
m.logger.Warn("failed to get container labels",
zap.String("container_id", ctr.ID),
zap.Error(err))
continue
}
instanceID := labels["kandev.instance_id"]
taskID := labels["kandev.task_id"]
agentProfileID := labels["kandev.agent_profile_id"]
if instanceID == "" || taskID == "" {
m.logger.Warn("container missing required labels",
zap.String("container_id", ctr.ID))
continue
}
// Get container IP
containerIP, err := m.docker.GetContainerIP(ctx, ctr.ID)
if err != nil {
m.logger.Warn("failed to get container IP",
zap.String("container_id", ctr.ID),
zap.Error(err))
containerIP = "localhost"
}
// Create instance record
instance := &AgentInstance{
ID: instanceID,
TaskID: taskID,
AgentProfileID: agentProfileID,
ContainerID: ctr.ID,
ContainerIP: containerIP,
Status: v1.AgentStatusRunning,
StartedAt: time.Now(), // We don't know exact start time
}
// Create agentctl client
instance.agentctl = agentctl.NewClient(containerIP, AgentCtlPort, m.logger)
// Add to tracking maps
m.instances[instanceID] = instance
m.byTask[taskID] = instanceID
m.byContainer[ctr.ID] = instanceID
recovered = append(recovered, RecoveredInstance{
InstanceID: instanceID,
TaskID: taskID,
ContainerID: ctr.ID,
AgentProfileID: agentProfileID,
})
// Reconnect to WebSocket streams for updates and permissions
// This runs in goroutines so we don't block reconciliation
go m.reconnectStreams(instance)
m.logger.Info("recovered running container",
zap.String("instance_id", instanceID),
zap.String("task_id", taskID),
zap.String("container_id", ctr.ID))
}
m.logger.Info("reconciliation complete",
zap.Int("containers_found", len(containers)),
zap.Int("recovered", len(recovered)))
return recovered, nil
}
// GetRecoveredInstances returns a snapshot of all currently tracked instances
// This can be used by the orchestrator to sync with the database
func (m *Manager) GetRecoveredInstances() []RecoveredInstance {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]RecoveredInstance, 0, len(m.instances))
for _, inst := range m.instances {
result = append(result, RecoveredInstance{
InstanceID: inst.ID,
TaskID: inst.TaskID,
ContainerID: inst.ContainerID,
AgentProfileID: inst.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
}
// Launch launches a new agent for a task
func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstance, error) {
m.logger.Info("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)
}
// Map agent name to registry ID (e.g., "auggie" -> "auggie-agent")
agentTypeName = profileInfo.AgentName + "-agent"
m.logger.Info("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, err := m.registry.Get(agentTypeName)
if err != nil {
return nil, fmt.Errorf("agent type %q not found in registry: %w", agentTypeName, err)
}
if !agentConfig.Enabled {
return nil, fmt.Errorf("agent type %q is disabled", agentTypeName)
}
// 3. Check if task already has an agent running
m.mu.RLock()
if existingID, exists := m.byTask[req.TaskID]; exists {
m.mu.RUnlock()
return nil, fmt.Errorf("task %q already has an agent running (instance: %s)", req.TaskID, existingID)
}
m.mu.RUnlock()
// 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")
m.logger.Info("using worktree for agent",
zap.String("task_id", req.TaskID),
zap.String("worktree_id", worktreeID),
zap.String("worktree_path", workspacePath),
zap.String("main_repo_git_dir", mainRepoGitDir),
zap.String("branch", wt.Branch))
}
} else if req.RepositoryPath != "" && workspacePath == "" {
// No worktree, but we have a repository path - use it as workspace
workspacePath = req.RepositoryPath
m.logger.Info("using repository path as workspace (no worktree)",
zap.String("task_id", req.TaskID),
zap.String("workspace_path", workspacePath))
}
// 5. Generate a new instance ID
instanceID := uuid.New().String()
// 6. Prepare request with worktree path
reqWithWorktree := *req
reqWithWorktree.WorkspacePath = workspacePath
// 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["AGENT_AUTO_APPROVE"] = "true"
}
if profileInfo.DangerouslySkipPermissions {
reqWithWorktree.Env["AGENT_DANGEROUSLY_SKIP_PERMISSIONS"] = "true"
}
if profileInfo.Plan != "" {
reqWithWorktree.Env["AGENT_PLAN"] = profileInfo.Plan
}
}
// 7. Launch via appropriate runtime (Docker or Standalone)
var instance *AgentInstance
if m.IsStandaloneMode() {
instance, err = m.launchStandalone(ctx, instanceID, &reqWithWorktree, agentConfig, profileInfo, worktreeID, worktreeBranch)
} else {
instance, err = m.launchDocker(ctx, instanceID, &reqWithWorktree, agentConfig, profileInfo, mainRepoGitDir, worktreeID, worktreeBranch)
}
if err != nil {
return nil, err
}
// 8. Track the instance
m.mu.Lock()
m.instances[instanceID] = instance
m.byTask[req.TaskID] = instanceID
if instance.ContainerID != "" {
m.byContainer[instance.ContainerID] = instanceID
}
m.mu.Unlock()
// 9. Publish agent.started event
m.publishEvent(ctx, events.AgentStarted, instance)
// 10. Wait for agentctl to be ready and start the agent process
go m.initializeAgent(instance, req.TaskDescription)
m.logger.Info("agent launched successfully",
zap.String("instance_id", instanceID),
zap.String("task_id", req.TaskID),
zap.String("runtime", m.agentCfg.Runtime))
return instance, nil
}
// launchDocker launches an agent in a Docker container
func (m *Manager) launchDocker(ctx context.Context, instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, profileInfo *AgentProfileInfo, mainRepoGitDir, worktreeID, worktreeBranch string) (*AgentInstance, error) {
// Build container config from registry config
containerConfig := m.buildContainerConfig(instanceID, req, agentConfig, profileInfo, mainRepoGitDir)
// Create and start the container
containerID, err := m.docker.CreateContainer(ctx, containerConfig)
if err != nil {
return nil, fmt.Errorf("failed to create container: %w", err)
}
if err := m.docker.StartContainer(ctx, containerID); err != nil {
_ = m.docker.RemoveContainer(ctx, containerID, true)
return nil, fmt.Errorf("failed to start container: %w", err)
}
// Get container IP for agentctl communication
containerIP, err := m.docker.GetContainerIP(ctx, containerID)
if err != nil {
m.logger.Warn("failed to get container IP, trying localhost",
zap.String("container_id", containerID),
zap.Error(err))
containerIP = "127.0.0.1"
}
// Create agentctl client pointing to the container
agentctlClient := agentctl.NewClient(containerIP, AgentCtlPort, m.logger)
// Build instance metadata
instanceMetadata := req.Metadata
if instanceMetadata == nil {
instanceMetadata = make(map[string]interface{})
}
if worktreeID != "" {
instanceMetadata["worktree_id"] = worktreeID
instanceMetadata["worktree_path"] = req.WorkspacePath
instanceMetadata["worktree_branch"] = worktreeBranch
}
m.logger.Info("docker agent created",
zap.String("instance_id", instanceID),
zap.String("container_id", containerID),
zap.String("container_ip", containerIP))
return &AgentInstance{
ID: instanceID,
TaskID: req.TaskID,
AgentProfileID: req.AgentProfileID,
ContainerID: containerID,
ContainerIP: containerIP,
WorkspacePath: "/workspace", // Docker mounts workspace to /workspace
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Progress: 0,
Metadata: instanceMetadata,
agentctl: agentctlClient,
}, nil
}
// launchStandalone launches an agent via standalone agentctl
func (m *Manager) launchStandalone(ctx context.Context, instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, profileInfo *AgentProfileInfo, worktreeID, worktreeBranch string) (*AgentInstance, error) {
// Build environment variables for the agent
env := m.buildStandaloneEnv(instanceID, req, agentConfig)
// Build agent command from registry config
// If Cmd is specified, join it; otherwise use default auggie command
agentCommand := ""
if len(agentConfig.Cmd) > 0 {
agentCommand = strings.Join(agentConfig.Cmd, " ")
}
// Append model flag if agent supports it and profile has a model configured
if agentConfig.ModelFlag != "" && profileInfo != nil && profileInfo.Model != "" {
agentCommand = agentCommand + " " + agentConfig.ModelFlag + " " + profileInfo.Model
}
// If empty, agentctl will use its default (auggie --acp)
// Create instance via control API
createReq := &agentctl.CreateInstanceRequest{
ID: instanceID,
WorkspacePath: req.WorkspacePath,
AgentCommand: agentCommand,
Env: env,
AutoStart: false, // We'll start via ACP initialize flow
}
resp, err := m.standaloneCtl.CreateInstance(ctx, createReq)
if err != nil {
return nil, fmt.Errorf("failed to create standalone instance: %w", err)
}
// Create agentctl client pointing to the instance port
agentctlClient := agentctl.NewClient(m.agentCfg.StandaloneHost, resp.Port, m.logger)
// Build instance metadata
instanceMetadata := req.Metadata
if instanceMetadata == nil {
instanceMetadata = make(map[string]interface{})
}
if worktreeID != "" {
instanceMetadata["worktree_id"] = worktreeID
instanceMetadata["worktree_path"] = req.WorkspacePath
instanceMetadata["worktree_branch"] = worktreeBranch
}
// Store standalone-specific info in metadata
instanceMetadata["standalone_port"] = resp.Port
m.logger.Info("standalone agent created",
zap.String("instance_id", instanceID),
zap.Int("port", resp.Port),
zap.String("workspace", req.WorkspacePath))
return &AgentInstance{
ID: instanceID,
TaskID: req.TaskID,
AgentProfileID: req.AgentProfileID,
WorkspacePath: req.WorkspacePath,
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Progress: 0,
Metadata: instanceMetadata,
agentctl: agentctlClient,
standaloneInstanceID: resp.ID,
standalonePort: resp.Port,
}, nil
}
// buildStandaloneEnv builds environment variables for a standalone agent instance
func (m *Manager) buildStandaloneEnv(instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig) map[string]string {
env := make(map[string]string)
// Copy request environment
for k, v := range req.Env {
env[k] = v
}
// Add standard variables
env["KANDEV_INSTANCE_ID"] = instanceID
env["KANDEV_TASK_ID"] = req.TaskID
env["TASK_DESCRIPTION"] = req.TaskDescription
// Add credentials if available
if m.credsMgr != nil {
ctx := context.Background()
if sessionAuth, err := m.credsMgr.GetCredentialValue(ctx, "AUGMENT_SESSION_AUTH"); err == nil && sessionAuth != "" {
env["AUGMENT_SESSION_AUTH"] = sessionAuth
}
}
// Check for ACP session resumption
// Only pass session ID if the session file exists locally
// Docker sessions won't exist on standalone hosts and vice versa
if req.Metadata != nil {
if sessionID, ok := req.Metadata["auggie_session_id"].(string); ok && sessionID != "" {
// Check if session file exists
homeDir, _ := os.UserHomeDir()
sessionPath := filepath.Join(homeDir, ".augment", "sessions", sessionID+".json")
if _, err := os.Stat(sessionPath); err == nil {
env["AUGGIE_SESSION_ID"] = sessionID
} else {
// Session file doesn't exist - don't pass the ID
// This happens when switching between Docker and standalone modes
}
}
}
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,
TaskTitle: req.TaskTitle,
RepositoryID: req.RepositoryID,
RepositoryPath: req.RepositoryPath,
BaseBranch: req.BaseBranch,
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.Info("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
}
// initializeAgent waits for agentctl to be ready and initializes the agent
func (m *Manager) initializeAgent(instance *AgentInstance, taskDescription string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
m.logger.Info("waiting for agentctl to be ready",
zap.String("instance_id", instance.ID),
zap.String("url", instance.agentctl.BaseURL()))
// Wait for agentctl HTTP server to be ready
if err := instance.agentctl.WaitForReady(ctx, 60*time.Second); err != nil {
m.logger.Error("agentctl not ready",
zap.String("instance_id", instance.ID),
zap.Error(err))
m.updateInstanceError(instance.ID, "agentctl not ready: "+err.Error())
return
}
// Start the agent process via agentctl
if err := instance.agentctl.Start(ctx); err != nil {
m.logger.Error("failed to start agent via agentctl",
zap.String("instance_id", instance.ID),
zap.Error(err))
m.updateInstanceError(instance.ID, "failed to start agent: "+err.Error())
return
}
m.logger.Info("agent process started via agentctl",
zap.String("instance_id", instance.ID))
// Give the agent process a moment to initialize
time.Sleep(500 * time.Millisecond)
// Initialize ACP session
if err := m.initializeACPSession(ctx, instance, taskDescription); err != nil {
m.logger.Error("failed to initialize ACP session",
zap.String("instance_id", instance.ID),
zap.Error(err))
m.updateInstanceError(instance.ID, "failed to initialize ACP: "+err.Error())
return
}
}
// initializeACPSession sends the ACP initialization messages using the acp-go-sdk
func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInstance, taskDescription string) error {
m.logger.Info("initializing ACP session",
zap.String("instance_id", instance.ID),
zap.String("agentctl_url", instance.agentctl.BaseURL()))
// 1. Send initialize request
m.logger.Info("sending ACP initialize request",
zap.String("instance_id", instance.ID))
initResp, err := instance.agentctl.Initialize(ctx, "kandev", "1.0.0")
if err != nil {
m.logger.Error("ACP initialize failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("initialize failed: %w", err)
}
m.logger.Info("ACP initialize response received",
zap.String("instance_id", instance.ID),
zap.String("agent_name", initResp.AgentInfo.Name),
zap.String("agent_version", initResp.AgentInfo.Version))
// 2. Create new session
m.logger.Info("sending ACP session/new request",
zap.String("instance_id", instance.ID),
zap.String("workspace_path", instance.WorkspacePath))
sessionID, err := instance.agentctl.NewSession(ctx, instance.WorkspacePath)
if err != nil {
m.logger.Error("ACP session/new failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("session/new failed: %w", err)
}
m.logger.Info("ACP session created",
zap.String("instance_id", instance.ID),
zap.String("session_id", sessionID))
// Store session ID in instance
m.mu.Lock()
if inst, exists := m.instances[instance.ID]; exists {
if inst.Metadata == nil {
inst.Metadata = make(map[string]interface{})
}
inst.Metadata["acp_session_id"] = sessionID
}
m.mu.Unlock()
// Publish session_info event so the orchestrator can store it in task metadata
m.publishSessionInfo(instance, sessionID)
// 3. Set up updates stream to receive session notifications
go m.handleUpdatesStream(instance)
// 4. Set up permission stream to receive permission requests
go m.handlePermissionStream(instance)
// 5. Set up git status stream to track workspace changes
go m.handleGitStatusStream(instance)
// 6. Set up file changes stream to track filesystem changes
go m.handleFileChangesStream(instance)
// 7. Send the task prompt if provided
if taskDescription != "" {
m.logger.Info("sending ACP prompt",
zap.String("instance_id", instance.ID),
zap.String("session_id", sessionID),
zap.String("task_description", taskDescription))
// Clear message buffer before starting prompt
instance.messageMu.Lock()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
// Prompt is SYNCHRONOUS - it blocks until the agent completes the task
// Use a long timeout context for this
promptCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
resp, err := instance.agentctl.Prompt(promptCtx, taskDescription)
if err != nil {
m.logger.Error("ACP prompt failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("prompt failed: %w", err)
}
// Extract accumulated message from buffer
instance.messageMu.Lock()
agentMessage := instance.messageBuffer.String()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
stopReason := ""
if resp != nil {
stopReason = string(resp.StopReason)
}
m.logger.Info("ACP prompt completed",
zap.String("instance_id", instance.ID),
zap.String("stop_reason", stopReason))
// Publish prompt_complete event with the agent's response (for saving as comment)
m.publishPromptComplete(instance, agentMessage)
// Prompt completed - mark agent as READY for follow-up prompts
m.logger.Info("agent ready for follow-up prompts",
zap.String("instance_id", instance.ID))
if err := m.MarkReady(instance.ID); err != nil {
m.logger.Error("failed to mark instance as ready",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
} else {
m.logger.Warn("no task description provided, marking as ready",
zap.String("instance_id", instance.ID))
if err := m.MarkReady(instance.ID); err != nil {
m.logger.Error("failed to mark instance as ready",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
}
return nil
}
// handleUpdatesStream handles streaming session notifications from the agent
func (m *Manager) handleUpdatesStream(instance *AgentInstance) {
ctx := context.Background()
err := instance.agentctl.StreamUpdates(ctx, func(notification agentctl.SessionNotification) {
m.handleSessionNotification(instance, notification)
})
if err != nil {
m.logger.Error("failed to connect to updates stream",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
}
// handlePermissionStream handles streaming permission requests from the agent
func (m *Manager) handlePermissionStream(instance *AgentInstance) {
ctx := context.Background()
err := instance.agentctl.StreamPermissions(ctx, func(notification *agentctl.PermissionNotification) {
m.handlePermissionNotification(instance, notification)
})
if err != nil {
m.logger.Error("failed to connect to permission stream",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
}
// handleGitStatusStream handles streaming git status updates from the agent workspace
func (m *Manager) handleGitStatusStream(instance *AgentInstance) {
ctx := context.Background()
// Retry connection with exponential backoff
maxRetries := 5
backoff := 1 * time.Second
for attempt := 1; attempt <= maxRetries; attempt++ {
err := instance.agentctl.StreamGitStatus(ctx, func(update *agentctl.GitStatusUpdate) {
m.handleGitStatusUpdate(instance, update)
})
if err == nil {
// Connection closed normally
return
}
if attempt < maxRetries {
time.Sleep(backoff)
backoff *= 2 // Exponential backoff
}
}
m.logger.Error("failed to connect to git status stream after retries",
zap.String("instance_id", instance.ID),
zap.Int("max_retries", maxRetries))
}
// reconnectStreams reconnects to agent WebSocket streams after backend restart
// This is called for recovered instances that were running before the restart
func (m *Manager) reconnectStreams(instance *AgentInstance) {
m.logger.Info("reconnecting to agent streams after recovery",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID))
// Wait a moment for any startup operations to settle
time.Sleep(500 * time.Millisecond)
// Check if agentctl is responsive
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := instance.agentctl.WaitForReady(ctx, 10*time.Second); err != nil {
m.logger.Warn("agentctl not ready for stream reconnection",
zap.String("instance_id", instance.ID),
zap.Error(err))
// Don't return - still try to connect to streams
}
// Reconnect to WebSocket streams
go m.handleUpdatesStream(instance)
go m.handlePermissionStream(instance)
go m.handleGitStatusStream(instance)
go m.handleFileChangesStream(instance)
// Mark the instance as READY so it can accept prompts
m.mu.Lock()
instance.Status = v1.AgentStatusReady
m.mu.Unlock()
m.logger.Info("agent streams reconnected, ready for prompts",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID))
}
// handlePermissionNotification processes incoming permission requests from the agent
func (m *Manager) handlePermissionNotification(instance *AgentInstance, notification *agentctl.PermissionNotification) {
m.logger.Info("received permission request",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.String("pending_id", notification.PendingID),
zap.String("title", notification.Title),
zap.Int("num_options", len(notification.Options)))
// Publish permission request event to the event bus
m.publishPermissionRequest(instance, notification)
}
// publishPermissionRequest publishes a permission request event
func (m *Manager) publishPermissionRequest(instance *AgentInstance, notification *agentctl.PermissionNotification) {
// Convert options to a serializable format
options := make([]map[string]interface{}, len(notification.Options))
for i, opt := range notification.Options {
options[i] = map[string]interface{}{
"option_id": opt.OptionID,
"name": opt.Name,
"kind": opt.Kind,
}
}
data := map[string]interface{}{
"type": "permission_request",
"task_id": instance.TaskID,
"agent_instance_id": instance.ID,
"pending_id": notification.PendingID,
"session_id": notification.SessionID,
"tool_call_id": notification.ToolCallID,
"title": notification.Title,
"options": options,
"created_at": notification.CreatedAt,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(instance.TaskID)
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish permission_request event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
} else {
m.logger.Info("published permission_request event",
zap.String("task_id", instance.TaskID),
zap.String("pending_id", notification.PendingID),
zap.String("title", notification.Title))
}
}
// handleSessionNotification processes incoming session notifications from the agent
func (m *Manager) handleSessionNotification(instance *AgentInstance, notification agentctl.SessionNotification) {
m.logger.Debug("received session notification",
zap.String("instance_id", instance.ID),
zap.String("session_id", string(notification.SessionId)))
update := notification.Update
// Handle different update types
if update.AgentMessageChunk != nil {
m.logger.Debug("agent message chunk",
zap.String("instance_id", instance.ID))
m.updateInstanceProgress(instance.ID, 50)
// Accumulate message content for saving as comment when a step completes
if update.AgentMessageChunk.Content.Text != nil {
instance.messageMu.Lock()
instance.messageBuffer.WriteString(update.AgentMessageChunk.Content.Text.Text)
instance.messageMu.Unlock()
}
}
if update.AgentThoughtChunk != nil {
m.logger.Debug("agent thought chunk",
zap.String("instance_id", instance.ID))
}
if update.ToolCall != nil {
// Tool call starting marks a step boundary - flush the accumulated message as a comment
// This way, each agent response before a tool use becomes a separate comment
m.flushMessageBufferAsComment(instance)
toolCallID := string(update.ToolCall.ToolCallId)
toolTitle := update.ToolCall.Title
toolStatus := string(update.ToolCall.Status)
toolKind := string(update.ToolCall.Kind)
m.logger.Info("tool call started",
zap.String("instance_id", instance.ID),
zap.String("tool_call_id", toolCallID),
zap.String("title", toolTitle),
zap.String("kind", toolKind))
m.updateInstanceProgress(instance.ID, 60)
// Extract args from the tool call for display
args := map[string]interface{}{}
// Add tool kind
if toolKind != "" {
args["kind"] = toolKind
}
// Add locations (file paths)
if len(update.ToolCall.Locations) > 0 {
locations := make([]map[string]interface{}, len(update.ToolCall.Locations))
for i, loc := range update.ToolCall.Locations {
locMap := map[string]interface{}{"path": loc.Path}
if loc.Line != nil {
locMap["line"] = *loc.Line
}
locations[i] = locMap
}
args["locations"] = locations
// Also set primary path for convenience
args["path"] = update.ToolCall.Locations[0].Path
}
// Add raw input if available
if update.ToolCall.RawInput != nil {
args["raw_input"] = update.ToolCall.RawInput
}
// Publish tool call as a comment so it appears in the chat
m.publishToolCall(instance, toolCallID, toolTitle, toolStatus, args)
}
if update.ToolCallUpdate != nil {
toolCallID := string(update.ToolCallUpdate.ToolCallId)
m.logger.Debug("tool call update",
zap.String("instance_id", instance.ID),
zap.String("tool_call_id", toolCallID))
// Check if tool call completed
if update.ToolCallUpdate.Status != nil {
status := string(*update.ToolCallUpdate.Status)
switch status {
case "complete":
m.logger.Info("tool call completed",
zap.String("instance_id", instance.ID))
m.updateInstanceProgress(instance.ID, 80)
// Publish tool call completion
m.publishToolCallComplete(instance, toolCallID, update.ToolCallUpdate)
case "error":
m.logger.Error("tool call error",
zap.String("instance_id", instance.ID))
m.publishToolCallComplete(instance, toolCallID, update.ToolCallUpdate)
}
}
}
if update.Plan != nil {
m.logger.Info("agent plan update",
zap.String("instance_id", instance.ID),
zap.Int("num_entries", len(update.Plan.Entries)))
}
// Publish session notification to event bus for WebSocket streaming
m.publishSessionNotification(instance, notification)
}
// publishSessionNotification publishes a session notification to the event bus
func (m *Manager) publishSessionNotification(instance *AgentInstance, notification agentctl.SessionNotification) {
if m.eventBus == nil {
return
}
// Build ACP message data from the session notification
data := map[string]interface{}{
"type": "session/update",
"timestamp": time.Now(),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"session_id": string(notification.SessionId),
"data": notification.Update,
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(instance.TaskID)
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish session notification",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// publishSessionInfo publishes a session_info event with the session ID
// This allows the orchestrator to store the session ID in task metadata for resumption
func (m *Manager) publishSessionInfo(instance *AgentInstance, sessionID string) {
if m.eventBus == nil {
return
}
// Structure must match protocol.Message so it can be parsed correctly
// The session_id goes in the "data" field, not at the top level
data := map[string]interface{}{
"type": "session_info",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"data": map[string]interface{}{
"session_id": sessionID,
},
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(instance.TaskID)
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish session_info event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.String("session_id", sessionID),
zap.Error(err))
} else {
m.logger.Info("published session_info event",
zap.String("task_id", instance.TaskID),
zap.String("session_id", sessionID))
}
}
// handleGitStatusUpdate processes git status updates from the workspace tracker
func (m *Manager) handleGitStatusUpdate(instance *AgentInstance, update *agentctl.GitStatusUpdate) {
// Store git status in instance metadata
m.mu.Lock()
if inst, exists := m.instances[instance.ID]; exists {
if inst.Metadata == nil {
inst.Metadata = make(map[string]interface{})
}
inst.Metadata["git_status"] = map[string]interface{}{
"branch": update.Branch,
"remote_branch": update.RemoteBranch,
"modified": update.Modified,
"added": update.Added,
"deleted": update.Deleted,
"untracked": update.Untracked,
"renamed": update.Renamed,
"ahead": update.Ahead,
"behind": update.Behind,
"timestamp": update.Timestamp,
}
}
m.mu.Unlock()
// Publish git status update to event bus for WebSocket streaming
m.publishGitStatus(instance, update)
}
// publishGitStatus publishes a git status update to the event bus
func (m *Manager) publishGitStatus(instance *AgentInstance, update *agentctl.GitStatusUpdate) {
if m.eventBus == nil {
return
}
data := map[string]interface{}{
"task_id": instance.TaskID,
"agent_id": instance.ID,
"branch": update.Branch,
"remote_branch": update.RemoteBranch,
"modified": update.Modified,
"added": update.Added,
"deleted": update.Deleted,
"untracked": update.Untracked,
"renamed": update.Renamed,
"ahead": update.Ahead,
"behind": update.Behind,
"files": update.Files,
"timestamp": update.Timestamp.Format(time.RFC3339Nano),
}
event := bus.NewEvent(events.GitStatusUpdated, "agent-manager", data)
subject := events.BuildGitStatusSubject(instance.TaskID)
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish git status event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// handleFileChangesStream handles streaming file change notifications from the agent workspace
func (m *Manager) handleFileChangesStream(instance *AgentInstance) {
ctx := context.Background()
// Retry connection with exponential backoff
maxRetries := 5
backoff := 1 * time.Second
for attempt := 1; attempt <= maxRetries; attempt++ {
err := instance.agentctl.StreamFileChanges(ctx, func(notification *agentctl.FileChangeNotification) {
m.publishFileChange(instance, notification)
})
if err == nil {
// Connection closed normally
return
}
if attempt < maxRetries {
time.Sleep(backoff)
backoff *= 2 // Exponential backoff
}
}
m.logger.Error("failed to connect to file changes stream after retries",
zap.String("instance_id", instance.ID),
zap.Int("max_retries", maxRetries))
}
// publishFileChange publishes a file change notification to the event bus
func (m *Manager) publishFileChange(instance *AgentInstance, notification *agentctl.FileChangeNotification) {
if m.eventBus == nil {
return
}
data := map[string]interface{}{
"task_id": instance.TaskID,
"agent_id": instance.ID,
"path": notification.Path,
"operation": notification.Operation,
"timestamp": notification.Timestamp.Format(time.RFC3339Nano),
}
event := bus.NewEvent(events.FileChangeNotified, "agent-manager", data)
subject := events.BuildFileChangeSubject(instance.TaskID)
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish file change event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// publishPromptComplete publishes a prompt_complete event when an agent finishes responding
// This is used to save the agent's response as a comment on the task
func (m *Manager) publishPromptComplete(instance *AgentInstance, agentMessage string) {
if m.eventBus == nil {
return
}
// Only publish if there's actual content
if agentMessage == "" {
return
}
data := map[string]interface{}{
"type": "prompt_complete",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"agent_message": agentMessage,
}
event := bus.NewEvent(events.PromptComplete, "agent-manager", data)
// Publish on task-specific subject so orchestrator can subscribe
subject := events.PromptComplete + "." + instance.TaskID
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish prompt_complete event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
} else {
m.logger.Info("published prompt_complete event",
zap.String("task_id", instance.TaskID),
zap.Int("message_length", len(agentMessage)))
}
}
// flushMessageBufferAsComment extracts any accumulated message from the buffer and
// publishes it as a step complete event (which will be saved as a comment).
// This is called when a tool use starts to save the agent's response before the tool call.
func (m *Manager) flushMessageBufferAsComment(instance *AgentInstance) {
instance.messageMu.Lock()
agentMessage := instance.messageBuffer.String()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
// Only publish if there's actual content (ignore whitespace-only)
trimmed := strings.TrimSpace(agentMessage)
if trimmed == "" {
return
}
m.logger.Debug("flushing message buffer as step comment",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Int("message_length", len(agentMessage)))
// Reuse the prompt_complete event type - the orchestrator handles it the same way
m.publishPromptComplete(instance, agentMessage)
}
// publishToolCall publishes a tool call start event (to be saved as a comment)
func (m *Manager) publishToolCall(instance *AgentInstance, toolCallID, title, status string, args map[string]interface{}) {
if m.eventBus == nil {
return
}
data := map[string]interface{}{
"type": "tool_call",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"tool_call_id": toolCallID,
"title": title,
"status": status,
}
if args != nil {
data["args"] = args
}
event := bus.NewEvent(events.ToolCallStarted, "agent-manager", data)
subject := events.ToolCallStarted + "." + instance.TaskID
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish tool_call event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
} else {
m.logger.Debug("published tool_call event",
zap.String("task_id", instance.TaskID),
zap.String("tool_call_id", toolCallID),
zap.String("title", title))
}
}
// publishToolCallComplete publishes a tool call completion event
func (m *Manager) publishToolCallComplete(instance *AgentInstance, toolCallID string, update *acp.SessionToolCallUpdate) {
if m.eventBus == nil {
return
}
status := ""
if update.Status != nil {
status = string(*update.Status)
}
data := map[string]interface{}{
"type": "tool_call_complete",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"tool_call_id": toolCallID,
"status": status,
}
event := bus.NewEvent(events.ToolCallComplete, "agent-manager", data)
subject := events.ToolCallComplete + "." + instance.TaskID
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish tool_call_complete event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// updateInstanceProgress updates an instance's progress
func (m *Manager) updateInstanceProgress(instanceID string, progress int) {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return
}
instance.Progress = progress
}
// updateInstanceComplete marks an instance as completed
func (m *Manager) updateInstanceComplete(instanceID string) {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return
}
instance.Status = v1.AgentStatusCompleted
instance.Progress = 100
now := time.Now()
instance.FinishedAt = &now
}
// updateInstanceError updates an instance with an error
func (m *Manager) updateInstanceError(instanceID, errorMsg string) {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return
}
instance.Status = v1.AgentStatusFailed
instance.ErrorMessage = errorMsg
now := time.Now()
instance.FinishedAt = &now
}
// buildContainerConfig builds a Docker container config from agent registry config
// mainRepoGitDir is the host path to the main repository's .git directory (e.g., /path/to/repo/.git)
// If non-empty, it will be mounted into the container at the same path so git worktree commands work correctly
func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, profileInfo *AgentProfileInfo, mainRepoGitDir string) docker.ContainerConfig {
// Build image name with tag
imageName := agentConfig.Image
if agentConfig.Tag != "" {
imageName = fmt.Sprintf("%s:%s", agentConfig.Image, agentConfig.Tag)
}
// Expand mount templates
mounts := make([]docker.MountConfig, 0, len(agentConfig.Mounts))
for _, mt := range agentConfig.Mounts {
// Skip workspace mounts if no workspace path is provided
if strings.Contains(mt.Source, "{workspace}") && req.WorkspacePath == "" {
m.logger.Debug("skipping workspace mount - no workspace path provided",
zap.String("target", mt.Target))
continue
}
source := m.expandMountTemplate(mt.Source, req.WorkspacePath, req.TaskID)
mounts = append(mounts, docker.MountConfig{
Source: source,
Target: mt.Target,
ReadOnly: mt.ReadOnly,
})
}
// Add main repository's .git directory mount if using a worktree
// Git worktrees have a .git file pointing to {repo}/.git/worktrees/{name}
// That metadata directory has a 'commondir' file pointing back to the main .git
// We need to mount the entire .git directory for git commands to work
if mainRepoGitDir != "" {
mounts = append(mounts, docker.MountConfig{
Source: mainRepoGitDir,
Target: mainRepoGitDir, // Same path inside container
ReadOnly: false,
})
m.logger.Debug("added main repo .git directory mount for worktree",
zap.String("path", mainRepoGitDir))
}
// Merge environment variables
env := make([]string, 0)
// Add default env from agent config
for k, v := range agentConfig.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// Add request-specific env vars (override defaults)
for k, v := range req.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// Add standard kandev env vars
env = append(env,
fmt.Sprintf("KANDEV_TASK_ID=%s", req.TaskID),
fmt.Sprintf("KANDEV_INSTANCE_ID=%s", instanceID),
)
// Configure Git to trust the workspace directory
// This is needed because the container runs as root but files are owned by host user
// Uses Git's environment-based config (GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_n)
env = append(env,
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=safe.directory",
"GIT_CONFIG_VALUE_0=*", // Trust all directories in the container
)
// Extract auggie_session_id from metadata for session resumption
if req.Metadata != nil {
if sessionID, ok := req.Metadata["auggie_session_id"].(string); ok && sessionID != "" {
env = append(env, fmt.Sprintf("AUGGIE_SESSION_ID=%s", sessionID))
}
}
// Inject required credentials from credentials manager
if m.credsMgr != nil && len(agentConfig.RequiredEnv) > 0 {
for _, key := range agentConfig.RequiredEnv {
value, err := m.credsMgr.GetCredentialValue(context.Background(), key)
if err != nil {
m.logger.Warn("required credential not found",
zap.String("key", key),
zap.Error(err))
continue
}
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
}
// Calculate resource limits
memoryBytes := agentConfig.ResourceLimits.MemoryMB * 1024 * 1024
cpuQuota := int64(agentConfig.ResourceLimits.CPUCores * 100000) // Docker CPU quota
containerName := fmt.Sprintf("kandev-agent-%s", instanceID[:8])
// Build command, appending model flag if agent supports it and profile has a model
cmd := make([]string, len(agentConfig.Cmd))
copy(cmd, agentConfig.Cmd)
if agentConfig.ModelFlag != "" && profileInfo != nil && profileInfo.Model != "" {
cmd = append(cmd, agentConfig.ModelFlag, profileInfo.Model)
}
return docker.ContainerConfig{
Name: containerName,
Image: imageName,
Cmd: cmd,
Env: env,
WorkingDir: agentConfig.WorkingDir,
Mounts: mounts,
Memory: memoryBytes,
CPUQuota: cpuQuota,
Labels: map[string]string{
"kandev.managed": "true",
"kandev.instance_id": instanceID,
"kandev.task_id": req.TaskID,
"kandev.agent_profile_id": req.AgentProfileID,
},
AutoRemove: false, // We manage cleanup ourselves
}
}
// expandMountTemplate expands template variables in mount source paths
func (m *Manager) expandMountTemplate(source, workspacePath, taskID string) string {
result := source
result = strings.ReplaceAll(result, "{workspace}", workspacePath)
result = strings.ReplaceAll(result, "{task_id}", taskID)
// Expand {augment_sessions} to the user's augment sessions directory
// This allows session resumption across container runs
if strings.Contains(result, "{augment_sessions}") {
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "/tmp"
}
augmentSessionsDir := filepath.Join(homeDir, ".augment", "sessions")
// Ensure the directory exists
_ = os.MkdirAll(augmentSessionsDir, 0755)
result = strings.ReplaceAll(result, "{augment_sessions}", augmentSessionsDir)
}
return result
}
// PromptAgent sends a follow-up prompt to a running agent
// PromptResult contains the result of a prompt operation
type PromptResult struct {
StopReason string // The reason the agent stopped (e.g., "end_turn")
AgentMessage string // The agent's accumulated response message
}
// PromptAgent sends a follow-up prompt to a running agent
func (m *Manager) PromptAgent(ctx context.Context, instanceID string, prompt string) (*PromptResult, error) {
m.mu.Lock()
instance, exists := m.instances[instanceID]
if !exists {
m.mu.Unlock()
return nil, fmt.Errorf("instance %q not found", instanceID)
}
if instance.agentctl == nil {
m.mu.Unlock()
return nil, fmt.Errorf("instance %q has no agentctl client", instanceID)
}
// Accept both RUNNING (initial) and READY (after first prompt) states
if instance.Status != v1.AgentStatusRunning && instance.Status != v1.AgentStatusReady {
m.mu.Unlock()
return nil, fmt.Errorf("instance %q is not ready for prompts (status: %s)", instanceID, instance.Status)
}
// Set status to RUNNING while processing
instance.Status = v1.AgentStatusRunning
// Clear message buffer before starting new prompt
instance.messageMu.Lock()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
m.mu.Unlock()
m.logger.Info("sending prompt to agent",
zap.String("instance_id", instanceID),
zap.Int("prompt_length", len(prompt)))
// Prompt is synchronous - blocks until agent completes
resp, err := instance.agentctl.Prompt(ctx, prompt)
if err != nil {
return nil, err
}
// Extract accumulated message from buffer
instance.messageMu.Lock()
agentMessage := instance.messageBuffer.String()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
result := &PromptResult{
StopReason: string(resp.StopReason),
AgentMessage: agentMessage,
}
// Publish prompt_complete event with the agent's response (for saving as comment)
m.publishPromptComplete(instance, agentMessage)
// Prompt completed - mark as READY for next prompt
if err := m.MarkReady(instanceID); err != nil {
m.logger.Error("failed to mark instance as ready after prompt",
zap.String("instance_id", instanceID),
zap.Error(err))
}
return result, nil
}
// StopAgent stops an agent instance
func (m *Manager) StopAgent(ctx context.Context, instanceID string, force bool) error {
m.mu.RLock()
instance, exists := m.instances[instanceID]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("instance %q not found", instanceID)
}
m.logger.Info("stopping agent",
zap.String("instance_id", instanceID),
zap.Bool("force", force),
zap.String("runtime", m.agentCfg.Runtime))
// Try to gracefully stop via agentctl first
if instance.agentctl != nil && !force {
if err := instance.agentctl.Stop(ctx); err != nil {
m.logger.Warn("failed to stop agent via agentctl",
zap.String("instance_id", instanceID),
zap.Error(err))
}
instance.agentctl.Close()
}
// Stop the agent instance based on runtime mode
var err error
if m.IsStandaloneMode() {
err = m.stopStandaloneInstance(ctx, instance)
} else {
err = m.stopDockerContainer(ctx, instance, force)
}
if err != nil {
return err
}
// Update instance status and remove from tracking maps
m.mu.Lock()
instance.Status = v1.AgentStatusStopped
now := time.Now()
instance.FinishedAt = &now
delete(m.instances, instanceID)
delete(m.byTask, instance.TaskID)
if instance.ContainerID != "" {
delete(m.byContainer, instance.ContainerID)
}
m.mu.Unlock()
m.logger.Info("agent stopped and removed from tracking",
zap.String("instance_id", instanceID),
zap.String("task_id", instance.TaskID))
// Publish stopped event
m.publishEvent(ctx, events.AgentStopped, instance)
return nil
}
// stopDockerContainer stops a Docker container
func (m *Manager) stopDockerContainer(ctx context.Context, instance *AgentInstance, force bool) error {
if instance.ContainerID == "" {
return nil // No container to stop
}
var err error
if force {
err = m.docker.KillContainer(ctx, instance.ContainerID, "SIGKILL")
} else {
err = m.docker.StopContainer(ctx, instance.ContainerID, 30*time.Second)
}
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
return nil
}
// stopStandaloneInstance stops a standalone agentctl instance
func (m *Manager) stopStandaloneInstance(ctx context.Context, instance *AgentInstance) error {
if instance.standaloneInstanceID == "" {
return nil // No standalone instance to stop
}
if err := m.standaloneCtl.DeleteInstance(ctx, instance.standaloneInstanceID); err != nil {
return fmt.Errorf("failed to stop standalone instance: %w", err)
}
return nil
}
// StopByTaskID stops the agent for a specific task
func (m *Manager) StopByTaskID(ctx context.Context, taskID string, force bool) error {
m.mu.RLock()
instanceID, exists := m.byTask[taskID]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("no agent running for task %q", taskID)
}
return m.StopAgent(ctx, instanceID, force)
}
// GetInstance returns an agent instance by ID
func (m *Manager) GetInstance(instanceID string) (*AgentInstance, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
instance, exists := m.instances[instanceID]
return instance, exists
}
// GetInstanceByTaskID returns the agent instance for a task
func (m *Manager) GetInstanceByTaskID(taskID string) (*AgentInstance, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
instanceID, exists := m.byTask[taskID]
if !exists {
return nil, false
}
instance, exists := m.instances[instanceID]
return instance, exists
}
// GetInstanceByContainerID returns the agent instance for a container
func (m *Manager) GetInstanceByContainerID(containerID string) (*AgentInstance, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
instanceID, exists := m.byContainer[containerID]
if !exists {
return nil, false
}
instance, exists := m.instances[instanceID]
return instance, exists
}
// ListInstances returns all active instances
func (m *Manager) ListInstances() []*AgentInstance {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*AgentInstance, 0, len(m.instances))
for _, instance := range m.instances {
result = append(result, instance)
}
return result
}
// IsAgentRunningForTask checks if an agent is actually running for a task
// This probes agentctl's status endpoint to verify the agent process is running
func (m *Manager) IsAgentRunningForTask(ctx context.Context, taskID string) bool {
// First check if we have an instance tracked for this task
instance, exists := m.GetInstanceByTaskID(taskID)
if !exists {
return false
}
// Probe agentctl status to verify the agent process is running
if instance.agentctl == nil {
return false
}
status, err := instance.agentctl.GetStatus(ctx)
if err != nil {
m.logger.Debug("failed to get agentctl status",
zap.String("task_id", taskID),
zap.Error(err))
return false
}
return status.IsAgentRunning()
}
// UpdateStatus updates the status of an instance
func (m *Manager) UpdateStatus(instanceID string, status v1.AgentStatus) error {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return fmt.Errorf("instance %q not found", instanceID)
}
instance.Status = status
m.logger.Debug("updated instance status",
zap.String("instance_id", instanceID),
zap.String("status", string(status)))
return nil
}
// UpdateProgress updates the progress of an instance
func (m *Manager) UpdateProgress(instanceID string, progress int) error {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return fmt.Errorf("instance %q not found", instanceID)
}
instance.Progress = progress
m.logger.Debug("updated instance progress",
zap.String("instance_id", instanceID),
zap.Int("progress", progress))
return nil
}
// MarkReady marks an instance as ready for follow-up prompts
func (m *Manager) MarkReady(instanceID string) error {
m.mu.Lock()
instance, exists := m.instances[instanceID]
if !exists {
m.mu.Unlock()
return fmt.Errorf("instance %q not found", instanceID)
}
instance.Status = v1.AgentStatusReady
m.mu.Unlock()
m.logger.Info("instance ready for follow-up prompts",
zap.String("instance_id", instanceID))
// Publish ready event
m.publishEvent(context.Background(), events.AgentReady, instance)
return nil
}
// MarkCompleted marks an instance as completed
func (m *Manager) MarkCompleted(instanceID string, exitCode int, errorMessage string) error {
m.mu.Lock()
instance, exists := m.instances[instanceID]
if !exists {
m.mu.Unlock()
return fmt.Errorf("instance %q not found", instanceID)
}
now := time.Now()
instance.FinishedAt = &now
instance.ExitCode = &exitCode
instance.ErrorMessage = errorMessage
if exitCode == 0 && errorMessage == "" {
instance.Status = v1.AgentStatusCompleted
instance.Progress = 100
} else {
instance.Status = v1.AgentStatusFailed
}
m.mu.Unlock()
m.logger.Info("instance completed",
zap.String("instance_id", instanceID),
zap.Int("exit_code", exitCode),
zap.String("status", string(instance.Status)))
// Publish completion event
eventType := events.AgentCompleted
if instance.Status == v1.AgentStatusFailed {
eventType = events.AgentFailed
}
m.publishEvent(context.Background(), eventType, instance)
return nil
}
// RemoveInstance removes a completed instance from tracking
func (m *Manager) RemoveInstance(instanceID string) {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return
}
delete(m.instances, instanceID)
delete(m.byTask, instance.TaskID)
delete(m.byContainer, instance.ContainerID)
m.logger.Debug("removed instance from tracking",
zap.String("instance_id", instanceID))
}
// publishEvent publishes an agent lifecycle event to NATS
func (m *Manager) publishEvent(ctx context.Context, eventType string, instance *AgentInstance) {
if m.eventBus == nil {
return
}
data := map[string]interface{}{
"instance_id": instance.ID,
"task_id": instance.TaskID,
"agent_profile_id": instance.AgentProfileID,
"container_id": instance.ContainerID,
"status": string(instance.Status),
"started_at": instance.StartedAt,
"progress": instance.Progress,
"error_message": instance.ErrorMessage,
}
if instance.FinishedAt != nil {
data["finished_at"] = *instance.FinishedAt
}
if instance.ExitCode != nil {
data["exit_code"] = *instance.ExitCode
}
event := bus.NewEvent(eventType, "agent-manager", data)
if err := m.eventBus.Publish(ctx, eventType, event); err != nil {
m.logger.Error("failed to publish event",
zap.String("event_type", eventType),
zap.String("instance_id", instance.ID),
zap.Error(err))
} else {
m.logger.Debug("published event",
zap.String("event_type", eventType),
zap.String("instance_id", instance.ID))
}
}
// 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
func (m *Manager) performCleanup(ctx context.Context) {
m.logger.Debug("running cleanup check")
// List all kandev-managed containers
containers, err := m.docker.ListContainers(ctx, map[string]string{
"kandev.managed": "true",
})
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" {
m.mu.RLock()
instanceID, tracked := m.byContainer[container.ID]
m.mu.RUnlock()
if tracked {
// Get container info to get exit code
info, err := m.docker.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 instance as completed
errorMsg := ""
if info.ExitCode != 0 {
errorMsg = fmt.Sprintf("container exited with code %d", info.ExitCode)
}
_ = m.MarkCompleted(instanceID, info.ExitCode, errorMsg)
// Remove the container
if err := m.docker.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 instance from tracking so new agents can be launched
m.RemoveInstance(instanceID)
}
}
}
}
// RespondToPermission sends a response to a permission request for an agent instance
func (m *Manager) RespondToPermission(instanceID, pendingID, optionID string, cancelled bool) error {
m.mu.RLock()
instance, exists := m.instances[instanceID]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("agent instance not found: %s", instanceID)
}
if instance.agentctl == nil {
return fmt.Errorf("agent instance has no agentctl client: %s", instanceID)
}
m.logger.Info("responding to permission request",
zap.String("instance_id", instanceID),
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 instance.agentctl.RespondToPermission(ctx, pendingID, optionID, cancelled)
}
// RespondToPermissionByTaskID sends a response to a permission request for a task
func (m *Manager) RespondToPermissionByTaskID(taskID, pendingID, optionID string, cancelled bool) error {
// Find the instance by task ID
m.mu.RLock()
var instance *AgentInstance
for _, inst := range m.instances {
if inst.TaskID == taskID {
instance = inst
break
}
}
m.mu.RUnlock()
if instance == nil {
return fmt.Errorf("no agent instance found for task: %s", taskID)
}
return m.RespondToPermission(instance.ID, pendingID, optionID, cancelled)
}