From 2e108b040f9df123fea25a86e10aba88754b595d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 16 Jan 2026 22:14:19 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20session=20resumption=20cleanup=20an?= =?UTF-8?q?d=20AgentInstance=20=E2=86=92=20AgentExecution=20rename=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: session resumption with ACP session ID support - Add useSessionResumption hook for automatic session recovery on page reload - Fix ACPSessionID not being set on AgentInstance in Launch() and createInstance() - Add task.session.status and task.session.resume WebSocket actions - Fix shell terminal retry logic for resumed sessions - Fix taskSessionState reading from Zustand store instead of local state - Remove dead code: unused stream handling functions in lifecycle/manager.go - Fix test mock interfaces (CleanupStaleInstanceByTaskID, NewManager signature) - Add enhanced logging for session resumption debugging - Simplify page-client.tsx state merging logic * refactor: remove unused AgentController (dead code) - Delete agent_controller.go - entirely unused duplicate of Manager methods - Remove agentController field from Manager struct - Remove AgentController initialization in NewManager The AgentController duplicated StartAgentProcess, initializeACPSession, and PromptAgent from Manager but was never called. All agent control operations go through Manager's own methods. * refactor: remove unused MemoryRepository (dead code) - Delete memory.go - entire MemoryRepository implementation unused (replaced by SQLite repository) - Delete memory_test.go - tests for removed MemoryRepository Found via 'deadcode' tool analysis. * refactor: consolidate agentctl packages into server/client structure Reorganize the confusing dual agentctl packages: - internal/agentctl/* → internal/agentctl/server/ - internal/agent/agentctl/* → internal/agentctl/client/ Structure is now: internal/agentctl/ ├── client/ - HTTP client for talking to agentctl servers │ ├── client.go │ ├── standalone.go │ ├── acp.go │ └── launcher/ ├── server/ - agentctl server implementation (runs in containers) │ ├── api/ │ ├── adapter/ │ ├── acp/ │ ├── config/ │ ├── instance/ │ ├── process/ │ └── shell/ └── types/ - Shared types used by both client and server Also consolidated shared types (GitStatusUpdate, FileInfo, etc.) from process/types.go into the shared types/ package. * refactor: rename AgentInstance to AgentExecution for clarity Rename AgentInstance -> AgentExecution across the entire codebase to better reflect what it represents: an agent actively working on a task, not just a generic 'instance'. Changes: - AgentInstance struct -> AgentExecution - InstanceStore -> ExecutionStore (new file: execution_store.go) - RecoveredInstance -> RecoveredExecution - All method names: GetInstance* -> GetExecution*, ListInstances -> ListExecutions, etc. - All parameters: instanceID -> executionID - All local variables: instance -> execution - DTOs and JSON tags: agent_instance_id -> agent_execution_id - Database columns: agent_instance_id -> agent_execution_id Also consolidates duplicate prompt logic: - Merge sendInitialPrompt and SendPrompt into single SendPrompt method - Add validateStatus parameter to differentiate initial vs follow-up prompts Also extracts ACP session management to session.go: - Move initializeACPSession logic to SessionManager.InitializeAndPrompt - Move PromptAgent logic to SessionManager.SendPrompt - Manager now delegates to SessionManager for all prompt operations Also removes duplicate event publishing: - Remove 9 duplicate m.publish*() methods from manager.go - Use existing EventPublisher methods via m.eventPublisher.Publish*() Also extracts types to dedicated file: - Move AgentExecution, LaunchRequest, PromptResult, etc. to types.go Net reduction: ~600 lines removed from manager.go through deduplication and better separation of concerns. * fix(backend): Docker runtime now creates instance via ControlServer The Docker runtime was incorrectly creating an agentctl.Client pointing directly to port 9999 (the ControlServer port), then trying to call instance-specific APIs like /api/v1/agent/configure which only exist on per-instance servers. Fixed by making Docker runtime follow the same flow as standalone mode: - LaunchContainer now uses StandaloneCtl to create an instance via the control API, then creates Client pointing to the instance port - RecoverInstances queries the ControlServer to get the instance port - Added waitForHealth helper to wait for agentctl to be ready Also improved HTTP error handling in agentctl client: - All client methods now check HTTP status codes before JSON decoding - Better error messages include status code and response body * fix: restore session resumption imports and variables * fix: add sessionId prop back to TaskLayout --- apps/backend/cmd/agentctl/main.go | 8 +- apps/backend/cmd/kandev/main.go | 128 +- .../internal/agent/controller/controller.go | 14 +- apps/backend/internal/agent/dto/dto.go | 34 +- .../internal/agent/handlers/shell_handlers.go | 67 +- .../agent/handlers/shell_handlers_test.go | 31 +- .../agent/handlers/workspace_file_handlers.go | 12 +- .../internal/agent/lifecycle/container.go | 72 +- .../internal/agent/lifecycle/events.go | 128 +- .../agent/lifecycle/execution_store.go | 193 ++ .../internal/agent/lifecycle/manager.go | 2166 +++++------------ .../internal/agent/lifecycle/manager_test.go | 278 +-- .../internal/agent/lifecycle/runtime.go | 105 + .../agent/lifecycle/runtime_docker.go | 186 ++ .../agent/lifecycle/runtime_standalone.go | 108 + .../internal/agent/lifecycle/session.go | 184 +- .../internal/agent/lifecycle/streams.go | 76 +- .../backend/internal/agent/lifecycle/types.go | 132 + .../internal/agent/registry/registry.go | 26 + .../agentctl => agentctl/client}/acp.go | 67 +- .../agentctl => agentctl/client}/client.go | 140 +- .../client}/launcher/launcher.go | 0 .../client}/launcher/sysprocattr_default.go | 0 .../client}/launcher/sysprocattr_linux.go | 0 .../client}/standalone.go | 2 +- .../internal/agentctl/process/types.go | 94 - .../agentctl/{ => server}/acp/client.go | 0 .../{ => server}/adapter/acp_adapter.go | 2 +- .../agentctl/{ => server}/adapter/adapter.go | 0 .../{ => server}/adapter/adapter_test.go | 0 .../{ => server}/adapter/codex_adapter.go | 0 .../internal/agentctl/{ => server}/api/acp.go | 0 .../{ => server}/api/control_server.go | 4 +- .../agentctl/{ => server}/api/server.go | 51 +- .../agentctl/{ => server}/api/workspace.go | 12 +- .../agentctl/{ => server}/config/config.go | 0 .../{ => server}/instance/instance.go | 2 +- .../agentctl/{ => server}/instance/manager.go | 4 +- .../{ => server}/instance/port_allocator.go | 0 .../agentctl/{ => server}/process/manager.go | 44 +- .../{ => server}/process/workspace_tracker.go | 71 +- .../agentctl/{ => server}/shell/session.go | 0 .../{ => server}/shell/session_test.go | 0 apps/backend/internal/agentctl/types/types.go | 95 +- .../internal/integration/orchestrator_test.go | 99 +- .../orchestrator/controller/controller.go | 49 +- apps/backend/internal/orchestrator/dto/dto.go | 75 +- .../orchestrator/executor/executor.go | 259 +- .../orchestrator/handlers/handlers.go | 33 + .../orchestrator/scheduler/scheduler_test.go | 26 +- apps/backend/internal/orchestrator/service.go | 108 +- .../internal/orchestrator/watcher/watcher.go | 18 +- .../orchestrator/watcher/watcher_test.go | 12 +- apps/backend/internal/task/dto/dto.go | 4 +- apps/backend/internal/task/models/models.go | 14 +- .../internal/task/repository/memory.go | 1355 ----------- .../internal/task/repository/memory_test.go | 530 ---- .../internal/task/repository/sqlite.go | 32 +- .../internal/task/repository/sqlite_test.go | 6 +- apps/backend/internal/task/service/service.go | 38 +- apps/backend/pkg/api/v1/agent.go | 10 +- apps/backend/pkg/codex/types.go | 13 +- apps/backend/pkg/websocket/actions.go | 1 + apps/backend/pkg/websocket/message.go | 2 - apps/web/app/task/[id]/page-client.tsx | 35 +- apps/web/components/task/shell-terminal.tsx | 45 +- apps/web/components/task/task-layout.tsx | 4 +- apps/web/components/task/task-right-panel.tsx | 5 +- apps/web/hooks/use-session-resumption.ts | 249 ++ 69 files changed, 3280 insertions(+), 4278 deletions(-) create mode 100644 apps/backend/internal/agent/lifecycle/execution_store.go create mode 100644 apps/backend/internal/agent/lifecycle/runtime.go create mode 100644 apps/backend/internal/agent/lifecycle/runtime_docker.go create mode 100644 apps/backend/internal/agent/lifecycle/runtime_standalone.go create mode 100644 apps/backend/internal/agent/lifecycle/types.go rename apps/backend/internal/{agent/agentctl => agentctl/client}/acp.go (82%) rename apps/backend/internal/{agent/agentctl => agentctl/client}/client.go (75%) rename apps/backend/internal/{agent/agentctl => agentctl/client}/launcher/launcher.go (100%) rename apps/backend/internal/{agent/agentctl => agentctl/client}/launcher/sysprocattr_default.go (100%) rename apps/backend/internal/{agent/agentctl => agentctl/client}/launcher/sysprocattr_linux.go (100%) rename apps/backend/internal/{agent/agentctl => agentctl/client}/standalone.go (99%) delete mode 100644 apps/backend/internal/agentctl/process/types.go rename apps/backend/internal/agentctl/{ => server}/acp/client.go (100%) rename apps/backend/internal/agentctl/{ => server}/adapter/acp_adapter.go (99%) rename apps/backend/internal/agentctl/{ => server}/adapter/adapter.go (100%) rename apps/backend/internal/agentctl/{ => server}/adapter/adapter_test.go (100%) rename apps/backend/internal/agentctl/{ => server}/adapter/codex_adapter.go (100%) rename apps/backend/internal/agentctl/{ => server}/api/acp.go (100%) rename apps/backend/internal/agentctl/{ => server}/api/control_server.go (96%) rename apps/backend/internal/agentctl/{ => server}/api/server.go (85%) rename apps/backend/internal/agentctl/{ => server}/api/workspace.go (91%) rename apps/backend/internal/agentctl/{ => server}/config/config.go (100%) rename apps/backend/internal/agentctl/{ => server}/instance/instance.go (98%) rename apps/backend/internal/agentctl/{ => server}/instance/manager.go (98%) rename apps/backend/internal/agentctl/{ => server}/instance/port_allocator.go (100%) rename apps/backend/internal/agentctl/{ => server}/process/manager.go (93%) rename apps/backend/internal/agentctl/{ => server}/process/workspace_tracker.go (89%) rename apps/backend/internal/agentctl/{ => server}/shell/session.go (100%) rename apps/backend/internal/agentctl/{ => server}/shell/session_test.go (100%) delete mode 100644 apps/backend/internal/task/repository/memory.go delete mode 100644 apps/backend/internal/task/repository/memory_test.go create mode 100644 apps/web/hooks/use-session-resumption.ts diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index 3270fb51e2..5c938a8520 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -17,10 +17,10 @@ import ( "syscall" "time" - "github.com/kandev/kandev/internal/agentctl/api" - "github.com/kandev/kandev/internal/agentctl/config" - "github.com/kandev/kandev/internal/agentctl/instance" - "github.com/kandev/kandev/internal/agentctl/process" + "github.com/kandev/kandev/internal/agentctl/server/api" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/agent" "go.uber.org/zap" diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 15bb5a6183..30632bd6c8 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -28,7 +28,8 @@ import ( gateways "github.com/kandev/kandev/internal/gateway/websocket" // Agent Manager packages - "github.com/kandev/kandev/internal/agent/agentctl/launcher" + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/client/launcher" agentcontroller "github.com/kandev/kandev/internal/agent/controller" "github.com/kandev/kandev/internal/agent/credentials" "github.com/kandev/kandev/internal/agent/discovery" @@ -300,10 +301,39 @@ func main() { credsMgr.AddProvider(credentials.NewFileProvider(credsFile)) } + // Profile Resolver + profileResolver := lifecycle.NewStoreProfileResolver(agentSettingsRepo) + + // Create the appropriate runtime based on configuration + var agentRuntime lifecycle.Runtime + var containerMgr *lifecycle.ContainerManager + switch cfg.Agent.Runtime { + case "standalone": + standaloneCtl := agentctl.NewStandaloneCtl( + cfg.Agent.StandaloneHost, + cfg.Agent.StandalonePort, + log, + ) + agentRuntime = lifecycle.NewStandaloneRuntime( + standaloneCtl, + cfg.Agent.StandaloneHost, + cfg.Agent.StandalonePort, + log, + ) + log.Info("Using standalone runtime", + zap.String("host", cfg.Agent.StandaloneHost), + zap.Int("port", cfg.Agent.StandalonePort)) + default: + // Docker mode (default) + if dockerClient != nil { + containerMgr = lifecycle.NewContainerManager(dockerClient, "", log) + agentRuntime = lifecycle.NewDockerRuntime(dockerClient, log) + log.Info("Using Docker runtime") + } + } + // Lifecycle Manager (uses agentctl for agent communication) - lifecycleMgr = lifecycle.NewManager(dockerClient, agentRegistry, eventBus, cfg.Agent, log) - lifecycleMgr.SetCredentialsManager(credsMgr) - lifecycleMgr.SetProfileResolver(lifecycle.NewStoreProfileResolver(agentSettingsRepo)) + lifecycleMgr = lifecycle.NewManager(agentRegistry, eventBus, agentRuntime, containerMgr, credsMgr, profileResolver, log) if err := lifecycleMgr.Start(ctx); err != nil { log.Fatal("Failed to start lifecycle manager", zap.Error(err)) @@ -421,6 +451,7 @@ func main() { // Register shell handlers shellHandlers := agenthandlers.NewShellHandlers(lifecycleMgr, log) shellHandlers.SetHub(gateway.Hub) + shellHandlers.SetSessionResumer(&shellSessionResumerAdapter{svc: orchestratorSvc}) shellHandlers.RegisterHandlers(gateway.Dispatcher) lifecycleMgr.SetShellStreamStarter(shellHandlers) log.Info("Registered Shell WebSocket handlers") @@ -896,7 +927,8 @@ func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *lo } } -// LaunchAgent starts a new agent container for a task +// LaunchAgent creates a new agentctl instance for a task. +// Agent subprocess is NOT started - call StartAgentProcess() explicitly. func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { // The RepositoryURL field contains a local filesystem path for the workspace // If empty, the agent will run without a mounted workspace @@ -917,68 +949,73 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch BaseBranch: req.BaseBranch, } - instance, err := a.mgr.Launch(ctx, launchReq) + // Create the agentctl execution (does NOT start agent process) + execution, err := a.mgr.Launch(ctx, launchReq) if err != nil { return nil, err } - // Streaming is now handled by the lifecycle manager - // Extract worktree info from metadata if available var worktreeID, worktreePath, worktreeBranch string - if instance.Metadata != nil { - if id, ok := instance.Metadata["worktree_id"].(string); ok { + if execution.Metadata != nil { + if id, ok := execution.Metadata["worktree_id"].(string); ok { worktreeID = id } - if path, ok := instance.Metadata["worktree_path"].(string); ok { + if path, ok := execution.Metadata["worktree_path"].(string); ok { worktreePath = path } - if branch, ok := instance.Metadata["worktree_branch"].(string); ok { + if branch, ok := execution.Metadata["worktree_branch"].(string); ok { worktreeBranch = branch } } return &executor.LaunchAgentResponse{ - AgentInstanceID: instance.ID, - ContainerID: instance.ContainerID, - Status: instance.Status, + AgentExecutionID: execution.ID, + ContainerID: execution.ContainerID, + Status: execution.Status, WorktreeID: worktreeID, WorktreePath: worktreePath, WorktreeBranch: worktreeBranch, }, nil } +// StartAgentProcess starts the agent subprocess for an instance. +// The command is built internally based on the instance's agent profile. +func (a *lifecycleAdapter) StartAgentProcess(ctx context.Context, agentInstanceID string) error { + return a.mgr.StartAgentProcess(ctx, agentInstanceID) +} + // StopAgent stops a running agent func (a *lifecycleAdapter) StopAgent(ctx context.Context, agentInstanceID string, force bool) error { return a.mgr.StopAgent(ctx, agentInstanceID, force) } -// GetAgentStatus returns the status of an agent instance -func (a *lifecycleAdapter) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) { - instance, found := a.mgr.GetInstance(agentInstanceID) +// GetAgentStatus returns the status of an agent execution +func (a *lifecycleAdapter) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentExecution, error) { + execution, found := a.mgr.GetExecution(agentInstanceID) if !found { - return nil, fmt.Errorf("agent instance %q not found", agentInstanceID) + return nil, fmt.Errorf("agent execution %q not found", agentInstanceID) } - containerID := instance.ContainerID + containerID := execution.ContainerID now := time.Now() - result := &v1.AgentInstance{ - ID: instance.ID, - TaskID: instance.TaskID, - AgentProfileID: instance.AgentProfileID, + result := &v1.AgentExecution{ + ID: execution.ID, + TaskID: execution.TaskID, + AgentProfileID: execution.AgentProfileID, ContainerID: &containerID, - Status: instance.Status, - StartedAt: &instance.StartedAt, - StoppedAt: instance.FinishedAt, - CreatedAt: instance.StartedAt, + Status: execution.Status, + StartedAt: &execution.StartedAt, + StoppedAt: execution.FinishedAt, + CreatedAt: execution.StartedAt, UpdatedAt: now, } - if instance.ExitCode != nil { - result.ExitCode = instance.ExitCode + if execution.ExitCode != nil { + result.ExitCode = execution.ExitCode } - if instance.ErrorMessage != "" { - result.ErrorMessage = &instance.ErrorMessage + if execution.ErrorMessage != "" { + result.ErrorMessage = &execution.ErrorMessage } return result, nil @@ -1011,13 +1048,13 @@ func (a *lifecycleAdapter) RespondToPermissionByTaskID(ctx context.Context, task return a.mgr.RespondToPermissionByTaskID(taskID, pendingID, optionID, cancelled) } -// GetRecoveredInstances returns instances recovered from Docker during startup -func (a *lifecycleAdapter) GetRecoveredInstances() []executor.RecoveredInstanceInfo { - recovered := a.mgr.GetRecoveredInstances() - result := make([]executor.RecoveredInstanceInfo, len(recovered)) +// GetRecoveredExecutions returns executions recovered from Docker during startup +func (a *lifecycleAdapter) GetRecoveredExecutions() []executor.RecoveredExecutionInfo { + recovered := a.mgr.GetRecoveredExecutions() + result := make([]executor.RecoveredExecutionInfo, len(recovered)) for i, r := range recovered { - result[i] = executor.RecoveredInstanceInfo{ - InstanceID: r.InstanceID, + result[i] = executor.RecoveredExecutionInfo{ + ExecutionID: r.ExecutionID, TaskID: r.TaskID, ContainerID: r.ContainerID, AgentProfileID: r.AgentProfileID, @@ -1032,6 +1069,11 @@ func (a *lifecycleAdapter) IsAgentRunningForTask(ctx context.Context, taskID str return a.mgr.IsAgentRunningForTask(ctx, taskID) } +// CleanupStaleExecutionByTaskID removes a stale agent execution from tracking without trying to stop it. +func (a *lifecycleAdapter) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { + return a.mgr.CleanupStaleExecutionByTaskID(ctx, taskID) +} + // corsMiddleware returns a CORS middleware for HTTP and WebSocket connections func corsMiddleware() gin.HandlerFunc { return func(c *gin.Context) { @@ -1071,6 +1113,16 @@ func (a *orchestratorAdapter) ResumeTaskSession(ctx context.Context, taskID, tas return err } +// shellSessionResumerAdapter adapts the orchestrator service to the shell handlers' SessionResumer interface +type shellSessionResumerAdapter struct { + svc *orchestrator.Service +} + +func (a *shellSessionResumerAdapter) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error { + _, err := a.svc.ResumeTaskSession(ctx, taskID, taskSessionID) + return err +} + // messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface type messageCreatorAdapter struct { svc *taskservice.Service diff --git a/apps/backend/internal/agent/controller/controller.go b/apps/backend/internal/agent/controller/controller.go index aa2c620cae..bf035dec90 100644 --- a/apps/backend/internal/agent/controller/controller.go +++ b/apps/backend/internal/agent/controller/controller.go @@ -29,14 +29,14 @@ func (c *Controller) ListAgents(ctx context.Context, req dto.ListAgentsRequest) return dto.ListAgentsResponse{}, ErrLifecycleManagerNotAvailable } - agents := c.lifecycle.ListInstances() + agents := c.lifecycle.ListExecutions() resp := dto.ListAgentsResponse{ Agents: make([]dto.AgentDTO, 0, len(agents)), Total: len(agents), } for _, a := range agents { - agent := dto.FromAgentInstance(&dto.AgentInstanceData{ + agent := dto.FromAgentExecution(&dto.AgentExecutionData{ ID: a.ID, TaskID: a.TaskID, AgentProfileID: a.AgentProfileID, @@ -67,14 +67,14 @@ func (c *Controller) LaunchAgent(ctx context.Context, req dto.LaunchAgentRequest Env: req.Env, } - instance, err := c.lifecycle.Launch(ctx, launchReq) + execution, err := c.lifecycle.Launch(ctx, launchReq) if err != nil { return dto.LaunchAgentResponse{}, err } return dto.LaunchAgentResponse{ Success: true, - AgentID: instance.ID, + AgentID: execution.ID, TaskID: req.TaskID, }, nil } @@ -85,12 +85,12 @@ func (c *Controller) GetAgentStatus(ctx context.Context, req dto.GetAgentStatusR return dto.AgentDTO{}, ErrLifecycleManagerNotAvailable } - agent, found := c.lifecycle.GetInstance(req.AgentID) + agent, found := c.lifecycle.GetExecution(req.AgentID) if !found { return dto.AgentDTO{}, ErrAgentNotFound } - return dto.FromAgentInstance(&dto.AgentInstanceData{ + return dto.FromAgentExecution(&dto.AgentExecutionData{ ID: agent.ID, TaskID: agent.TaskID, AgentProfileID: agent.AgentProfileID, @@ -110,7 +110,7 @@ func (c *Controller) GetAgentLogs(ctx context.Context, req dto.GetAgentLogsReque return dto.GetAgentLogsResponse{}, ErrLifecycleManagerNotAvailable } - _, found := c.lifecycle.GetInstance(req.AgentID) + _, found := c.lifecycle.GetExecution(req.AgentID) if !found { return dto.GetAgentLogsResponse{}, ErrAgentNotFound } diff --git a/apps/backend/internal/agent/dto/dto.go b/apps/backend/internal/agent/dto/dto.go index 6c0f7c1259..87817fc918 100644 --- a/apps/backend/internal/agent/dto/dto.go +++ b/apps/backend/internal/agent/dto/dto.go @@ -88,31 +88,31 @@ type ListAgentTypesResponse struct { Total int `json:"total"` } -// FromAgentInstance converts a lifecycle.AgentInstance to AgentDTO -func FromAgentInstance(a *AgentInstanceData) AgentDTO { +// FromAgentExecution converts a lifecycle.AgentExecution to AgentDTO +func FromAgentExecution(execution *AgentExecutionData) AgentDTO { agent := AgentDTO{ - ID: a.ID, - TaskID: a.TaskID, - AgentProfileID: a.AgentProfileID, - ContainerID: a.ContainerID, - Status: a.Status, - Progress: a.Progress, - StartedAt: a.StartedAt.Format("2006-01-02T15:04:05Z"), + ID: execution.ID, + TaskID: execution.TaskID, + AgentProfileID: execution.AgentProfileID, + ContainerID: execution.ContainerID, + Status: execution.Status, + Progress: execution.Progress, + StartedAt: execution.StartedAt.Format("2006-01-02T15:04:05Z"), } - if a.FinishedAt != nil && !a.FinishedAt.IsZero() { - agent.FinishedAt = a.FinishedAt.Format("2006-01-02T15:04:05Z") + if execution.FinishedAt != nil && !execution.FinishedAt.IsZero() { + agent.FinishedAt = execution.FinishedAt.Format("2006-01-02T15:04:05Z") } - if a.ExitCode != nil { - agent.ExitCode = a.ExitCode + if execution.ExitCode != nil { + agent.ExitCode = execution.ExitCode } - if a.ErrorMessage != "" { - agent.Error = a.ErrorMessage + if execution.ErrorMessage != "" { + agent.Error = execution.ErrorMessage } return agent } -// AgentInstanceData is the input data for FromAgentInstance (avoids circular import) -type AgentInstanceData struct { +// AgentExecutionData is the input data for FromAgentExecution (avoids circular import) +type AgentExecutionData struct { ID string TaskID string AgentProfileID string diff --git a/apps/backend/internal/agent/handlers/shell_handlers.go b/apps/backend/internal/agent/handlers/shell_handlers.go index 786649bcf7..141aecae65 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers.go +++ b/apps/backend/internal/agent/handlers/shell_handlers.go @@ -6,18 +6,24 @@ import ( "fmt" "sync" - "github.com/kandev/kandev/internal/agent/agentctl" + agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) +// SessionResumer is an interface for resuming task sessions +type SessionResumer interface { + ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error +} + // ShellHandlers provides WebSocket handlers for shell terminal operations type ShellHandlers struct { - lifecycleMgr *lifecycle.Manager - logger *logger.Logger - hub ShellOutputBroadcaster + lifecycleMgr *lifecycle.Manager + logger *logger.Logger + hub ShellOutputBroadcaster + sessionResumer SessionResumer // Track active shell streams per task activeStreams map[string]context.CancelFunc @@ -43,6 +49,11 @@ func (h *ShellHandlers) SetHub(hub ShellOutputBroadcaster) { h.hub = hub } +// SetSessionResumer sets the session resumer for auto-resuming sessions on shell subscribe +func (h *ShellHandlers) SetSessionResumer(resumer SessionResumer) { + h.sessionResumer = resumer +} + // RegisterHandlers registers shell handlers with the WebSocket dispatcher func (h *ShellHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionShellStatus, h.wsShellStatus) @@ -72,8 +83,8 @@ func (h *ShellHandlers) wsShellStatus(ctx context.Context, msg *ws.Message) (*ws return nil, fmt.Errorf("task_id is required") } - // Get the agent instance for this task - instance, ok := h.lifecycleMgr.GetInstanceByTaskID(req.TaskID) + // Get the agent execution for this task + execution, ok := h.lifecycleMgr.GetExecutionByTaskID(req.TaskID) if !ok { return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ "available": false, @@ -82,7 +93,7 @@ func (h *ShellHandlers) wsShellStatus(ctx context.Context, msg *ws.Message) (*ws } // Get shell status from agentctl - client := instance.GetAgentCtlClient() + client := execution.GetAgentCtlClient() if client == nil { return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ "available": false, @@ -124,8 +135,8 @@ func (h *ShellHandlers) wsShellSubscribe(ctx context.Context, msg *ws.Message) ( return nil, fmt.Errorf("task_id is required") } - // Verify the agent instance exists for this task - instance, ok := h.lifecycleMgr.GetInstanceByTaskID(req.TaskID) + // Verify the agent execution exists for this task + execution, ok := h.lifecycleMgr.GetExecutionByTaskID(req.TaskID) if !ok { return nil, fmt.Errorf("no agent running for task %s", req.TaskID) } @@ -138,7 +149,7 @@ func (h *ShellHandlers) wsShellSubscribe(ctx context.Context, msg *ws.Message) ( // Get buffered output to include in response // This ensures client gets current shell state without duplicate broadcasts buffer := "" - if client := instance.GetAgentCtlClient(); client != nil { + if client := execution.GetAgentCtlClient(); client != nil { if b, err := client.ShellBuffer(ctx); err == nil { buffer = b } @@ -162,8 +173,8 @@ func (h *ShellHandlers) wsShellInput(ctx context.Context, msg *ws.Message) (*ws. return nil, fmt.Errorf("task_id is required") } - // Verify the agent instance exists for this task - if _, ok := h.lifecycleMgr.GetInstanceByTaskID(req.TaskID); !ok { + // Verify the agent execution exists for this task + if _, ok := h.lifecycleMgr.GetExecutionByTaskID(req.TaskID); !ok { return nil, fmt.Errorf("no agent running for task %s", req.TaskID) } @@ -194,12 +205,22 @@ func (h *ShellHandlers) StartShellStreamWithHub(ctx context.Context, taskID stri return nil // Already streaming } - streamCtx, cancel := context.WithCancel(ctx) + streamCtx, cancel := context.WithCancel(context.Background()) // Use background context so stream survives request h.activeStreams[taskID] = cancel h.mu.Unlock() - go h.runShellStream(streamCtx, taskID, hub) - return nil + // Use a channel to wait for stream setup to complete + readyCh := make(chan error, 1) + go h.runShellStreamWithReady(streamCtx, taskID, hub, readyCh) + + // Wait for stream to be ready (or fail) + select { + case err := <-readyCh: + return err + case <-ctx.Done(): + cancel() + return ctx.Err() + } } // StopShellStream stops the shell stream for a task @@ -217,29 +238,32 @@ type ShellOutputBroadcaster interface { BroadcastToTask(taskID string, msg *ws.Message) } -// runShellStream runs the shell output stream for a task -func (h *ShellHandlers) runShellStream(ctx context.Context, taskID string, hub ShellOutputBroadcaster) { +// runShellStreamWithReady runs the shell output stream for a task and signals when ready +func (h *ShellHandlers) runShellStreamWithReady(ctx context.Context, taskID string, hub ShellOutputBroadcaster, readyCh chan<- error) { defer func() { h.mu.Lock() delete(h.activeStreams, taskID) h.mu.Unlock() }() - instance, ok := h.lifecycleMgr.GetInstanceByTaskID(taskID) + execution, ok := h.lifecycleMgr.GetExecutionByTaskID(taskID) if !ok { - h.logger.Debug("no agent instance for shell stream", zap.String("task_id", taskID)) + h.logger.Debug("no agent execution for shell stream", zap.String("task_id", taskID)) + readyCh <- fmt.Errorf("no agent execution for task %s", taskID) return } - client := instance.GetAgentCtlClient() + client := execution.GetAgentCtlClient() if client == nil { h.logger.Debug("no client for shell stream", zap.String("task_id", taskID)) + readyCh <- fmt.Errorf("no agent client for task %s", taskID) return } outputCh, inputCh, err := client.StreamShell(ctx) if err != nil { h.logger.Error("failed to start shell stream", zap.String("task_id", taskID), zap.Error(err)) + readyCh <- fmt.Errorf("failed to start shell stream: %w", err) return } @@ -249,6 +273,9 @@ func (h *ShellHandlers) runShellStream(ctx context.Context, taskID string, hub S h.logger.Info("shell stream started", zap.String("task_id", taskID)) + // Signal that we're ready - input channel is now stored + readyCh <- nil + for { select { case <-ctx.Done(): diff --git a/apps/backend/internal/agent/handlers/shell_handlers_test.go b/apps/backend/internal/agent/handlers/shell_handlers_test.go index 342919b6fc..3e4471ec4b 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers_test.go +++ b/apps/backend/internal/agent/handlers/shell_handlers_test.go @@ -8,7 +8,6 @@ import ( "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" ws "github.com/kandev/kandev/pkg/websocket" @@ -42,13 +41,24 @@ func (m *MockEventBus) IsConnected() bool { return true } -// testAgentConfig returns a default AgentConfig for testing -func testAgentConfig() config.AgentConfig { - return config.AgentConfig{ - Runtime: "docker", - StandaloneHost: "localhost", - StandalonePort: 9999, - } +// MockCredentialsManager implements lifecycle.CredentialsManager for testing +type MockCredentialsManager struct{} + +func (m *MockCredentialsManager) GetCredentialValue(ctx context.Context, key string) (string, error) { + return "", nil +} + +// MockProfileResolver implements lifecycle.ProfileResolver for testing +type MockProfileResolver struct{} + +func (m *MockProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*lifecycle.AgentProfileInfo, error) { + return &lifecycle.AgentProfileInfo{ + ProfileID: profileID, + ProfileName: "Test Profile", + AgentID: "augment-agent", + AgentName: "auggie", + Model: "claude-sonnet-4-20250514", + }, nil } func newTestRegistry() *registry.Registry { @@ -176,7 +186,10 @@ func newTestManager() *lifecycle.Manager { log := newTestLogger() reg := newTestRegistry() eventBus := &MockEventBus{} - return lifecycle.NewManager(nil, reg, eventBus, testAgentConfig(), log) + credsMgr := &MockCredentialsManager{} + profileResolver := &MockProfileResolver{} + // Pass nil for runtime and containerManager - tests don't need them + return lifecycle.NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, log) } func TestWsShellStatus_NoInstanceFound(t *testing.T) { diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index 6228024c97..b64b254d1b 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -46,14 +46,14 @@ func (h *WorkspaceFileHandlers) wsGetFileTree(ctx context.Context, msg *ws.Messa return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) } - // Get agent instance for this task - instance, found := h.lifecycle.GetInstanceByTaskID(req.TaskID) + // Get agent execution for this task + execution, found := h.lifecycle.GetExecutionByTaskID(req.TaskID) if !found { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for task", nil) } // Get agentctl client - client := instance.GetAgentCtlClient() + client := execution.GetAgentCtlClient() if client == nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil) } @@ -87,14 +87,14 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil) } - // Get agent instance for this task - instance, found := h.lifecycle.GetInstanceByTaskID(req.TaskID) + // Get agent execution for this task + execution, found := h.lifecycle.GetExecutionByTaskID(req.TaskID) if !found { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for task", nil) } // Get agentctl client - client := instance.GetAgentCtlClient() + client := execution.GetAgentCtlClient() if client == nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil) } diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index 5dab9f7618..bce1766f80 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -10,7 +10,7 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/agentctl" + agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/common/logger" @@ -48,8 +48,8 @@ func NewContainerManager(dockerClient *docker.Client, networkName string, log *l } } -// LaunchContainer creates and starts a Docker container for an agent -// Returns the container ID and agentctl client +// LaunchContainer creates and starts a Docker container for an agent. +// Returns the container ID and agentctl client pointing to the instance. func (cm *ContainerManager) LaunchContainer(ctx context.Context, config ContainerConfig) (string, *agentctl.Client, error) { // Build container config containerCfg, err := cm.buildContainerConfig(config) @@ -78,17 +78,60 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe containerIP = "127.0.0.1" } - // Create agentctl client pointing to the container - agentctlClient := agentctl.NewClient(containerIP, AgentCtlPort, cm.logger) + // Create StandaloneCtl to communicate with the container's ControlServer + ctl := agentctl.NewStandaloneCtl(containerIP, AgentCtlPort, cm.logger) + + // Wait for agentctl to be healthy + if err := cm.waitForHealth(ctx, ctl); err != nil { + _ = cm.dockerClient.RemoveContainer(ctx, containerID, true) + return "", nil, fmt.Errorf("agentctl health check failed: %w", err) + } + + // Create an instance via the control API (same flow as standalone mode) + createReq := &agentctl.CreateInstanceRequest{ + ID: config.InstanceID, + WorkspacePath: "/workspace", + AgentCommand: "", // Agent command set via Configure endpoint later + Env: config.Credentials, + AutoStart: false, + } + + resp, err := ctl.CreateInstance(ctx, createReq) + if err != nil { + _ = cm.dockerClient.RemoveContainer(ctx, containerID, true) + return "", nil, fmt.Errorf("failed to create instance in container: %w", err) + } + + // Create agentctl client pointing to the instance port + agentctlClient := agentctl.NewClient(containerIP, resp.Port, cm.logger) cm.logger.Info("docker container launched", zap.String("container_id", containerID), zap.String("container_ip", containerIP), - zap.String("instance_id", config.InstanceID)) + zap.String("instance_id", config.InstanceID), + zap.Int("instance_port", resp.Port)) return containerID, agentctlClient, nil } +// waitForHealth waits for agentctl to be healthy with retries +func (cm *ContainerManager) waitForHealth(ctx context.Context, ctl *agentctl.StandaloneCtl) error { + const maxRetries = 30 + const retryDelay = 500 * time.Millisecond + + for i := 0; i < maxRetries; i++ { + if err := ctl.Health(ctx); err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + time.Sleep(retryDelay) + } + + return fmt.Errorf("agentctl not healthy after %d retries", maxRetries) +} + // StopContainer stops and removes a Docker container func (cm *ContainerManager) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error { if containerID == "" { @@ -256,3 +299,20 @@ func (cm *ContainerManager) buildEnvVars(config ContainerConfig) []string { return env } + +// ListManagedContainers returns all containers managed by kandev +func (cm *ContainerManager) ListManagedContainers(ctx context.Context) ([]docker.ContainerInfo, error) { + return cm.dockerClient.ListContainers(ctx, map[string]string{ + "kandev.managed": "true", + }) +} + +// GetContainerInfo returns information about a specific container +func (cm *ContainerManager) GetContainerInfo(ctx context.Context, containerID string) (*docker.ContainerInfo, error) { + return cm.dockerClient.GetContainerInfo(ctx, containerID) +} + +// RemoveContainer removes a container +func (cm *ContainerManager) RemoveContainer(ctx context.Context, containerID string, force bool) error { + return cm.dockerClient.RemoveContainer(ctx, containerID, force) +} diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 2367d82d00..4787d83c5f 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/agentctl" + agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" @@ -29,27 +29,27 @@ func NewEventPublisher(eventBus bus.EventBus, log *logger.Logger) *EventPublishe } // PublishAgentEvent publishes an agent lifecycle event (started, stopped, ready, completed, failed). -func (p *EventPublisher) PublishAgentEvent(ctx context.Context, eventType string, instance *AgentInstance) { +func (p *EventPublisher) PublishAgentEvent(ctx context.Context, eventType string, execution *AgentExecution) { if p.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, + "instance_id": execution.ID, + "task_id": execution.TaskID, + "agent_profile_id": execution.AgentProfileID, + "container_id": execution.ContainerID, + "status": string(execution.Status), + "started_at": execution.StartedAt, + "progress": execution.Progress, + "error_message": execution.ErrorMessage, } - if instance.FinishedAt != nil { - data["finished_at"] = *instance.FinishedAt + if execution.FinishedAt != nil { + data["finished_at"] = *execution.FinishedAt } - if instance.ExitCode != nil { - data["exit_code"] = *instance.ExitCode + if execution.ExitCode != nil { + data["exit_code"] = *execution.ExitCode } event := bus.NewEvent(eventType, "agent-manager", data) @@ -57,24 +57,24 @@ func (p *EventPublisher) PublishAgentEvent(ctx context.Context, eventType string if err := p.eventBus.Publish(ctx, eventType, event); err != nil { p.logger.Error("failed to publish event", zap.String("event_type", eventType), - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Error(err)) } else { p.logger.Debug("published agent event", zap.String("event_type", eventType), - zap.String("instance_id", instance.ID)) + zap.String("instance_id", execution.ID)) } } // PublishACPSessionCreated publishes an event when an ACP session is created. -func (p *EventPublisher) PublishACPSessionCreated(instance *AgentInstance, sessionID string) { +func (p *EventPublisher) PublishACPSessionCreated(execution *AgentExecution, sessionID string) { if p.eventBus == nil || sessionID == "" { return } data := map[string]interface{}{ - "task_id": instance.TaskID, - "agent_instance_id": instance.ID, + "task_id": execution.TaskID, + "agent_instance_id": execution.ID, "acp_session_id": sessionID, } @@ -82,13 +82,13 @@ func (p *EventPublisher) PublishACPSessionCreated(instance *AgentInstance, sessi if err := p.eventBus.Publish(context.Background(), events.AgentACPSessionCreated, event); err != nil { p.logger.Error("failed to publish ACP session event", zap.String("event_type", events.AgentACPSessionCreated), - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Error(err)) } } // PublishSessionUpdate publishes a session update to the event bus for WebSocket streaming. -func (p *EventPublisher) PublishSessionUpdate(instance *AgentInstance, update agentctl.SessionUpdate) { +func (p *EventPublisher) PublishSessionUpdate(execution *AgentExecution, update agentctl.SessionUpdate) { if p.eventBus == nil { return } @@ -133,32 +133,32 @@ func (p *EventPublisher) PublishSessionUpdate(instance *AgentInstance, update ag data := map[string]interface{}{ "type": "session/update", "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": instance.ID, - "task_id": instance.TaskID, + "agent_id": execution.ID, + "task_id": execution.TaskID, "session_id": update.SessionID, "data": updateData, } event := bus.NewEvent(events.ACPMessage, "agent-manager", data) - subject := events.BuildACPSubject(instance.TaskID) + subject := events.BuildACPSubject(execution.TaskID) if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { p.logger.Error("failed to publish session update", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), zap.Error(err)) } } // PublishGitStatus publishes a git status update to the event bus. -func (p *EventPublisher) PublishGitStatus(instance *AgentInstance, update *agentctl.GitStatusUpdate) { +func (p *EventPublisher) PublishGitStatus(execution *AgentExecution, update *agentctl.GitStatusUpdate) { if p.eventBus == nil { return } data := map[string]interface{}{ - "task_id": instance.TaskID, - "agent_id": instance.ID, + "task_id": execution.TaskID, + "agent_id": execution.ID, "branch": update.Branch, "remote_branch": update.RemoteBranch, "modified": update.Modified, @@ -173,44 +173,44 @@ func (p *EventPublisher) PublishGitStatus(instance *AgentInstance, update *agent } event := bus.NewEvent(events.GitStatusUpdated, "agent-manager", data) - subject := events.BuildGitStatusSubject(instance.TaskID) + subject := events.BuildGitStatusSubject(execution.TaskID) if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { p.logger.Error("failed to publish git status event", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), zap.Error(err)) } } // PublishFileChange publishes a file change notification to the event bus. -func (p *EventPublisher) PublishFileChange(instance *AgentInstance, notification *agentctl.FileChangeNotification) { +func (p *EventPublisher) PublishFileChange(execution *AgentExecution, notification *agentctl.FileChangeNotification) { if p.eventBus == nil { return } data := map[string]interface{}{ - "task_id": instance.TaskID, - "agent_id": instance.ID, + "task_id": execution.TaskID, + "agent_id": execution.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) + subject := events.BuildFileChangeSubject(execution.TaskID) if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { p.logger.Error("failed to publish file change event", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.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 (p *EventPublisher) PublishPromptComplete(instance *AgentInstance, agentMessage, reasoning, summary string) { +func (p *EventPublisher) PublishPromptComplete(execution *AgentExecution, agentMessage, reasoning, summary string) { if p.eventBus == nil { return } @@ -223,8 +223,8 @@ func (p *EventPublisher) PublishPromptComplete(instance *AgentInstance, agentMes data := map[string]interface{}{ "type": "prompt_complete", "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": instance.ID, - "task_id": instance.TaskID, + "agent_id": execution.ID, + "task_id": execution.TaskID, "agent_message": agentMessage, } if reasoning != "" { @@ -236,18 +236,18 @@ func (p *EventPublisher) PublishPromptComplete(instance *AgentInstance, agentMes event := bus.NewEvent(events.PromptComplete, "agent-manager", data) // Publish on task-specific subject so orchestrator can subscribe - subject := events.PromptComplete + "." + instance.TaskID + subject := events.PromptComplete + "." + execution.TaskID if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { p.logger.Error("failed to publish prompt_complete event", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), zap.Error(err)) } } // PublishToolCall publishes a tool call start event (to be saved as a comment). -func (p *EventPublisher) PublishToolCall(instance *AgentInstance, toolCallID, title, status string, args map[string]interface{}) { +func (p *EventPublisher) PublishToolCall(execution *AgentExecution, toolCallID, title, status string, args map[string]interface{}) { if p.eventBus == nil { return } @@ -255,8 +255,8 @@ func (p *EventPublisher) PublishToolCall(instance *AgentInstance, toolCallID, ti data := map[string]interface{}{ "type": "tool_call", "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": instance.ID, - "task_id": instance.TaskID, + "agent_id": execution.ID, + "task_id": execution.TaskID, "tool_call_id": toolCallID, "title": title, "status": status, @@ -266,23 +266,23 @@ func (p *EventPublisher) PublishToolCall(instance *AgentInstance, toolCallID, ti } event := bus.NewEvent(events.ToolCallStarted, "agent-manager", data) - subject := events.ToolCallStarted + "." + instance.TaskID + subject := events.ToolCallStarted + "." + execution.TaskID if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { p.logger.Error("failed to publish tool_call event", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), zap.Error(err)) } else { p.logger.Debug("published tool_call event", - zap.String("task_id", instance.TaskID), + zap.String("task_id", execution.TaskID), zap.String("tool_call_id", toolCallID), zap.String("title", title)) } } // PublishToolCallComplete publishes a tool call completion event from a SessionUpdate. -func (p *EventPublisher) PublishToolCallComplete(instance *AgentInstance, update agentctl.SessionUpdate) { +func (p *EventPublisher) PublishToolCallComplete(execution *AgentExecution, update agentctl.SessionUpdate) { if p.eventBus == nil { return } @@ -290,25 +290,25 @@ func (p *EventPublisher) PublishToolCallComplete(instance *AgentInstance, update data := map[string]interface{}{ "type": "tool_call_complete", "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": instance.ID, - "task_id": instance.TaskID, + "agent_id": execution.ID, + "task_id": execution.TaskID, "tool_call_id": update.ToolCallID, "status": update.ToolStatus, } event := bus.NewEvent(events.ToolCallComplete, "agent-manager", data) - subject := events.ToolCallComplete + "." + instance.TaskID + subject := events.ToolCallComplete + "." + execution.TaskID if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { p.logger.Error("failed to publish tool_call_complete event", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), zap.Error(err)) } } // PublishPermissionRequest publishes a permission request event. -func (p *EventPublisher) PublishPermissionRequest(instance *AgentInstance, notification *agentctl.PermissionNotification) { +func (p *EventPublisher) PublishPermissionRequest(execution *AgentExecution, notification *agentctl.PermissionNotification) { if p.eventBus == nil { return } @@ -325,8 +325,8 @@ func (p *EventPublisher) PublishPermissionRequest(instance *AgentInstance, notif data := map[string]interface{}{ "type": "permission_request", - "task_id": instance.TaskID, - "agent_instance_id": instance.ID, + "task_id": execution.TaskID, + "agent_instance_id": execution.ID, "pending_id": notification.PendingID, "session_id": notification.SessionID, "tool_call_id": notification.ToolCallID, @@ -337,16 +337,16 @@ func (p *EventPublisher) PublishPermissionRequest(instance *AgentInstance, notif } event := bus.NewEvent(events.ACPMessage, "agent-manager", data) - subject := events.BuildACPSubject(instance.TaskID) + subject := events.BuildACPSubject(execution.TaskID) if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { p.logger.Error("failed to publish permission_request event", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), zap.Error(err)) } else { p.logger.Info("published permission_request event", - zap.String("task_id", instance.TaskID), + zap.String("task_id", execution.TaskID), zap.String("pending_id", notification.PendingID), zap.String("title", notification.Title)) } diff --git a/apps/backend/internal/agent/lifecycle/execution_store.go b/apps/backend/internal/agent/lifecycle/execution_store.go new file mode 100644 index 0000000000..cae89598a7 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/execution_store.go @@ -0,0 +1,193 @@ +// Package lifecycle manages agent execution lifecycles including tracking, +// state transitions, and cleanup. +package lifecycle + +import ( + "sync" + + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// ExecutionStore provides thread-safe storage and retrieval of agent executions. +// It maintains three indexes for efficient lookup by execution ID, task ID, and container ID. +type ExecutionStore struct { + executions map[string]*AgentExecution + byTask map[string]string // taskID -> executionID + byContainer map[string]string // containerID -> executionID + mu sync.RWMutex +} + +// NewExecutionStore creates a new ExecutionStore with initialized maps. +func NewExecutionStore() *ExecutionStore { + return &ExecutionStore{ + executions: make(map[string]*AgentExecution), + byTask: make(map[string]string), + byContainer: make(map[string]string), + } +} + +// Add adds an agent execution to all tracking maps. +// The execution must have a valid ID. TaskID and ContainerID are optional +// but will be indexed if present. +func (s *ExecutionStore) Add(execution *AgentExecution) { + if execution == nil || execution.ID == "" { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.executions[execution.ID] = execution + + if execution.TaskID != "" { + s.byTask[execution.TaskID] = execution.ID + } + + if execution.ContainerID != "" { + s.byContainer[execution.ContainerID] = execution.ID + } +} + +// Remove removes an agent execution from all tracking maps. +func (s *ExecutionStore) Remove(executionID string) { + s.mu.Lock() + defer s.mu.Unlock() + + execution, exists := s.executions[executionID] + if !exists { + return + } + + // Remove from secondary indexes + if execution.TaskID != "" { + delete(s.byTask, execution.TaskID) + } + if execution.ContainerID != "" { + delete(s.byContainer, execution.ContainerID) + } + + // Remove from primary map + delete(s.executions, executionID) +} + +// Get returns an agent execution by its ID. +func (s *ExecutionStore) Get(executionID string) (*AgentExecution, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + execution, exists := s.executions[executionID] + return execution, exists +} + +// GetByTaskID returns the agent execution associated with a task ID. +func (s *ExecutionStore) GetByTaskID(taskID string) (*AgentExecution, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + executionID, exists := s.byTask[taskID] + if !exists { + return nil, false + } + + execution, exists := s.executions[executionID] + return execution, exists +} + +// GetByContainerID returns the agent execution associated with a container ID. +func (s *ExecutionStore) GetByContainerID(containerID string) (*AgentExecution, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + executionID, exists := s.byContainer[containerID] + if !exists { + return nil, false + } + + execution, exists := s.executions[executionID] + return execution, exists +} + +// List returns all tracked agent executions. +func (s *ExecutionStore) List() []*AgentExecution { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]*AgentExecution, 0, len(s.executions)) + for _, execution := range s.executions { + result = append(result, execution) + } + return result +} + +// UpdateStatus updates the status of an agent execution. +func (s *ExecutionStore) UpdateStatus(executionID string, status v1.AgentStatus) { + s.mu.Lock() + defer s.mu.Unlock() + + if execution, exists := s.executions[executionID]; exists { + execution.Status = status + } +} + +// UpdateProgress updates the progress of an agent execution. +func (s *ExecutionStore) UpdateProgress(executionID string, progress int) { + s.mu.Lock() + defer s.mu.Unlock() + + if execution, exists := s.executions[executionID]; exists { + execution.Progress = progress + } +} + +// UpdateError updates the error message of an agent execution and sets its status to failed. +func (s *ExecutionStore) UpdateError(executionID string, errorMsg string) { + s.mu.Lock() + defer s.mu.Unlock() + + if execution, exists := s.executions[executionID]; exists { + execution.ErrorMessage = errorMsg + execution.Status = v1.AgentStatusFailed + } +} + +// UpdateMetadata updates the metadata of an agent execution using the provided function. +func (s *ExecutionStore) UpdateMetadata(executionID string, updateFn func(metadata map[string]interface{}) map[string]interface{}) { + s.mu.Lock() + defer s.mu.Unlock() + + if execution, exists := s.executions[executionID]; exists { + if execution.Metadata == nil { + execution.Metadata = make(map[string]interface{}) + } + execution.Metadata = updateFn(execution.Metadata) + } +} + +// WithLock executes a function with the store lock held, providing access to the execution. +// Returns false if the execution doesn't exist. +func (s *ExecutionStore) WithLock(executionID string, fn func(execution *AgentExecution)) bool { + s.mu.Lock() + defer s.mu.Unlock() + + execution, exists := s.executions[executionID] + if !exists { + return false + } + fn(execution) + return true +} + +// WithRLock executes a function with the store read lock held, providing access to the execution. +// Returns false if the execution doesn't exist. +func (s *ExecutionStore) WithRLock(executionID string, fn func(execution *AgentExecution)) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + execution, exists := s.executions[executionID] + if !exists { + return false + } + fn(execution) + return true +} + diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index dbe2e7f37a..c6686ac17c 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "os" "path/filepath" "strings" "sync" @@ -15,100 +14,17 @@ import ( "github.com/google/uuid" "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/agentctl" - "github.com/kandev/kandev/internal/agent/docker" + agentctl "github.com/kandev/kandev/internal/agentctl/client" "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) - ACPSessionID string // ACP session ID to resume, if available - 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 - - // Buffers for accumulating agent response during a prompt - messageBuffer strings.Builder - reasoningBuffer strings.Builder - summaryBuffer 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 - SessionID 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 - ACPSessionID string // ACP session ID to resume, if available - 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 @@ -116,25 +32,22 @@ type Manager struct { worktreeMgr *worktree.Manager logger *logger.Logger - // Agent runtime configuration - agentCfg config.AgentConfig - standaloneCtl *agentctl.StandaloneCtl // Client for standalone agentctl control API + // Agent runtime abstraction (Docker, Standalone, K8s, SSH, etc.) + runtime Runtime // 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 + containerManager *ContainerManager // Manages Docker containers (optional, nil for non-Docker runtimes) // Shell stream starter for auto-starting shell streams shellStreamStarter ShellStreamStarter - // 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 + // Workspace info provider for on-demand instance creation + workspaceInfoProvider WorkspaceInfoProvider // Background cleanup cleanupInterval time.Duration @@ -142,12 +55,16 @@ type Manager struct { wg sync.WaitGroup } -// NewManager creates a new lifecycle manager +// NewManager creates a new lifecycle manager. +// The runtime parameter is the agent execution runtime (Docker, Standalone, etc.). +// The containerManager parameter is optional and used for Docker cleanup (pass nil for non-Docker runtimes). func NewManager( - dockerClient *docker.Client, reg *registry.Registry, eventBus bus.EventBus, - agentCfg config.AgentConfig, + runtime Runtime, + containerManager *ContainerManager, + credsMgr CredentialsManager, + profileResolver ProfileResolver, log *logger.Logger, ) *Manager { componentLogger := log.WithFields(zap.String("component", "lifecycle-manager")) @@ -161,26 +78,21 @@ func NewManager( // Initialize event publisher eventPublisher := NewEventPublisher(eventBus, log) - // Initialize container manager (only if docker client is available) - // Note: Network configuration is not used currently - containers use default Docker networking - var containerManager *ContainerManager - if dockerClient != nil { - containerManager = NewContainerManager(dockerClient, "", log) - } + // Initialize execution store + executionStore := NewExecutionStore() mgr := &Manager{ - docker: dockerClient, registry: reg, eventBus: eventBus, - agentCfg: agentCfg, + runtime: runtime, + credsMgr: credsMgr, + profileResolver: profileResolver, logger: componentLogger, + executionStore: executionStore, commandBuilder: commandBuilder, sessionManager: sessionManager, eventPublisher: eventPublisher, containerManager: containerManager, - instances: make(map[string]*AgentInstance), - byTask: make(map[string]string), - byContainer: make(map[string]string), cleanupInterval: 30 * time.Second, stopCh: make(chan struct{}), } @@ -193,211 +105,214 @@ func NewManager( OnFileChange: mgr.handleFileChangeNotification, }) - // 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)) + // Set session manager dependencies for full orchestration + sessionManager.SetDependencies(eventPublisher, mgr.streamManager, executionStore) + + if runtime != nil { + mgr.logger.Info("initialized with runtime", zap.String("runtime", runtime.Name())) } 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 } -// ShellStreamStarter is called to start shell streaming for an agent instance -type ShellStreamStarter interface { - StartShellStream(ctx context.Context, taskID string) error -} - // SetShellStreamStarter sets the shell stream starter func (m *Manager) SetShellStreamStarter(starter ShellStreamStarter) { m.shellStreamStarter = starter } +// SetWorkspaceInfoProvider sets the provider for workspace information +func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) { + m.workspaceInfoProvider = provider +} + +// EnsureWorkspaceExecution ensures an agentctl execution exists for a task's workspace. +// If an execution already exists, it returns it. Otherwise, it creates a new execution +// for workspace access (shell, git, file operations) WITHOUT starting the agent process. +// This is used to restore workspace access after backend restart. +func (m *Manager) EnsureWorkspaceExecution(ctx context.Context, taskID string) (*AgentExecution, error) { + // Check if execution already exists + if execution, exists := m.executionStore.GetByTaskID(taskID); exists { + return execution, nil + } + + // Need to create a new execution - get workspace info + if m.workspaceInfoProvider == nil { + return nil, fmt.Errorf("workspace info provider not configured") + } + + info, err := m.workspaceInfoProvider.GetWorkspaceInfo(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("failed to get workspace info: %w", err) + } + + if info.WorkspacePath == "" { + return nil, fmt.Errorf("no workspace path available for task %s", taskID) + } + + m.logger.Info("creating execution for task", + zap.String("task_id", taskID), + zap.String("workspace_path", info.WorkspacePath)) + + return m.createExecution(ctx, taskID, info) +} + +// 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 task, 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.GetByTaskID(taskID); 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)) + + return m.createExecution(ctx, taskID, info) +} + +// 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) { + if m.runtime == nil { + return nil, fmt.Errorf("no runtime configured") + } + + if info.AgentID == "" { + return nil, fmt.Errorf("agent ID is required in WorkspaceInfo") + } + + executionID := uuid.New().String() + + agentConfig, err := m.registry.Get(info.AgentID) + if err != nil { + return nil, fmt.Errorf("agent type %q not found in registry: %w", info.AgentID, err) + } + + req := &RuntimeCreateRequest{ + InstanceID: executionID, + TaskID: taskID, + AgentProfileID: info.AgentProfileID, + WorkspacePath: info.WorkspacePath, + Protocol: string(agentConfig.Protocol), + } + + runtimeInstance, err := m.runtime.CreateInstance(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to create execution: %w", err) + } + + execution := runtimeInstance.ToAgentExecution(req) + + // 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", m.runtime.Name())) + + return execution, nil +} + // 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)) + runtimeName := "none" + if m.runtime != nil { + runtimeName = m.runtime.Name() + } + m.logger.Info("starting lifecycle manager", zap.String("runtime", runtimeName)) - 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") - } + if m.runtime == nil { + m.logger.Warn("no runtime configured") + return nil + } + + // Check runtime health + if err := m.runtime.HealthCheck(ctx); err != nil { + m.logger.Warn("runtime health check failed", + zap.String("runtime", runtimeName), + zap.Error(err)) + // Continue anyway - it might come up later } else { - // Docker mode - 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 - } + m.logger.Info("runtime is healthy", zap.String("runtime", runtimeName)) + } - // Start cleanup loop only if docker client is available - if m.docker != nil { - m.wg.Add(1) - go m.cleanupLoop(ctx) - m.logger.Info("cleanup loop started") - } else { - m.logger.Warn("cleanup loop not started - no docker client available") + // Try to recover executions from previous runs + recovered, err := m.runtime.RecoverInstances(ctx) + if err != nil { + m.logger.Warn("failed to recover executions", zap.Error(err)) + } else if len(recovered) > 0 { + for _, ri := range recovered { + execution := &AgentExecution{ + ID: ri.InstanceID, + TaskID: ri.TaskID, + ContainerID: ri.ContainerID, + ContainerIP: ri.ContainerIP, + WorkspacePath: ri.WorkspacePath, + Status: v1.AgentStatusRunning, + StartedAt: time.Now(), + Metadata: ri.Metadata, + agentctl: ri.Client, + standaloneInstanceID: ri.StandaloneInstanceID, + standalonePort: ri.StandalonePort, + } + m.executionStore.Add(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") } 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 +// GetRecoveredExecutions returns a snapshot of all currently tracked executions // 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, +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, + ContainerID: exec.ContainerID, + AgentProfileID: exec.AgentProfileID, }) } return result @@ -415,23 +330,17 @@ func (m *Manager) Stop() error { // StopAllAgents attempts a graceful shutdown of all active agents. func (m *Manager) StopAllAgents(ctx context.Context) error { - instanceIDs := make([]string, 0) - m.mu.RLock() - for instanceID := range m.instances { - instanceIDs = append(instanceIDs, instanceID) - } - m.mu.RUnlock() - - if len(instanceIDs) == 0 { + executions := m.executionStore.List() + if len(executions) == 0 { return nil } var errs []error - for _, instanceID := range instanceIDs { - if err := m.StopAgent(ctx, instanceID, false); err != nil { + for _, exec := range executions { + if err := m.StopAgent(ctx, exec.ID, false); err != nil { errs = append(errs, err) m.logger.Warn("failed to stop agent during shutdown", - zap.String("instance_id", instanceID), + zap.String("execution_id", exec.ID), zap.Error(err)) } } @@ -440,7 +349,7 @@ func (m *Manager) StopAllAgents(ctx context.Context) error { } // Launch launches a new agent for a task -func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstance, error) { +func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) { m.logger.Info("launching agent", zap.String("task_id", req.TaskID), zap.String("agent_profile_id", req.AgentProfileID), @@ -479,12 +388,9 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc } // 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) + if existingExecution, exists := m.executionStore.GetByTaskID(req.TaskID); exists { + return nil, fmt.Errorf("task %q already has an agent running (execution: %s)", req.TaskID, existingExecution.ID) } - m.mu.RUnlock() // 4. Handle worktree creation/reuse if enabled workspacePath := req.WorkspacePath @@ -509,28 +415,27 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc // 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() + // 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 + } + // 6b. Add profile settings to environment if profileInfo != nil { if reqWithWorktree.Env == nil { @@ -550,172 +455,150 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc } } - // 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) + // 7. Launch via runtime - creates agentctl instance with workspace access only + // Agent subprocess is NOT started - call StartAgentProcess() explicitly + if m.runtime == nil { + return nil, fmt.Errorf("no runtime configured") } + + // Build environment variables + env := m.buildEnvForRuntime(executionID, &reqWithWorktree, agentConfig) + + // Create runtime request (agent command not included - started explicitly later) + runtimeReq := &RuntimeCreateRequest{ + InstanceID: executionID, + TaskID: reqWithWorktree.TaskID, + AgentProfileID: reqWithWorktree.AgentProfileID, + WorkspacePath: reqWithWorktree.WorkspacePath, + Protocol: string(agentConfig.Protocol), + Env: env, + Metadata: reqWithWorktree.Metadata, + WorktreeID: worktreeID, + WorktreeBranch: worktreeBranch, + MainRepoGitDir: mainRepoGitDir, + AgentConfig: agentConfig, + } + + runtimeInstance, err := m.runtime.CreateInstance(ctx, runtimeReq) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create execution: %w", 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() + // Convert to AgentExecution + execution := runtimeInstance.ToAgentExecution(runtimeReq) - // 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, agentConfig, 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) + // Set ACP session ID for session resumption (used by InitializeSession) + if req.ACPSessionID != "" { + execution.ACPSessionID = req.ACPSessionID } - 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 - ACPSessionID: req.ACPSessionID, - 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 using CommandBuilder (configuration-driven, no hardcoded agent names) + // Build agent command string for later use with StartAgentProcess model := "" if profileInfo != nil { model = profileInfo.Model } cmdOpts := CommandOptions{ Model: model, - SessionID: req.ACPSessionID, // CommandBuilder handles resume flag based on SessionConfig + SessionID: req.ACPSessionID, } - agentCommand := m.commandBuilder.BuildCommandString(agentConfig, cmdOpts) - // If empty, agentctl will use its default + execution.AgentCommand = m.commandBuilder.BuildCommandString(agentConfig, cmdOpts) - // Create instance via control API - createReq := &agentctl.CreateInstanceRequest{ - ID: instanceID, - WorkspacePath: req.WorkspacePath, - AgentCommand: agentCommand, - Protocol: string(agentConfig.Protocol), // Pass protocol from registry - WorkspaceFlag: agentConfig.WorkspaceFlag, // CLI flag for workspace path - Env: env, - AutoStart: false, // We'll start via ACP initialize flow + // 8. Track the execution + m.executionStore.Add(execution) + + // 9. Publish agent.started event + m.eventPublisher.PublishAgentEvent(ctx, events.AgentStarted, 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) + + runtimeName := "unknown" + if m.runtime != nil { + runtimeName = m.runtime.Name() } + m.logger.Info("agentctl execution created (agent not started)", + zap.String("execution_id", executionID), + zap.String("task_id", req.TaskID), + zap.String("runtime", runtimeName)) - 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, - ACPSessionID: req.ACPSessionID, - Status: v1.AgentStatusRunning, - StartedAt: time.Now(), - Progress: 0, - Metadata: instanceMetadata, - agentctl: agentctlClient, - standaloneInstanceID: resp.ID, - standalonePort: resp.Port, - }, nil + return execution, nil } -// buildStandaloneEnv builds environment variables for a standalone agent instance -func (m *Manager) buildStandaloneEnv(instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig) map[string]string { +// 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) + } + + 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 + } + } + + // Build environment for the agent process + env := map[string]string{} + if taskDescription != "" { + env["TASK_DESCRIPTION"] = taskDescription + } + + // Configure the agent command + if err := execution.agentctl.ConfigureAgent(ctx, execution.AgentCommand, env); err != nil { + return fmt.Errorf("failed to configure agent: %w", err) + } + + // Start the agent process + if err := execution.agentctl.Start(ctx); err != nil { + m.updateExecutionError(executionID, "failed to start agent: "+err.Error()) + return fmt.Errorf("failed to start agent: %w", err) + } + + m.logger.Info("agent process started", + zap.String("execution_id", executionID), + zap.String("task_id", execution.TaskID), + zap.String("command", execution.AgentCommand)) + + // 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 { + return fmt.Errorf("failed to get agent config: %w", err) + } + + // Initialize ACP session + if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription); err != nil { + m.updateExecutionError(executionID, "failed to initialize ACP: "+err.Error()) + return fmt.Errorf("failed to initialize ACP: %w", err) + } + + return nil +} + +// 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 *registry.AgentTypeConfig) map[string]string { env := make(map[string]string) // Copy request environment @@ -723,16 +606,19 @@ func (m *Manager) buildStandaloneEnv(instanceID string, req *LaunchRequest, agen env[k] = v } - // Add standard variables - env["KANDEV_INSTANCE_ID"] = instanceID + // Add standard variables for recovery after backend restart + env["KANDEV_INSTANCE_ID"] = executionID env["KANDEV_TASK_ID"] = req.TaskID + env["KANDEV_AGENT_PROFILE_ID"] = req.AgentProfileID env["TASK_DESCRIPTION"] = req.TaskDescription - // Add credentials if available - if m.credsMgr != nil { + // Add required credentials from agent config + if m.credsMgr != nil && agentConfig != nil { ctx := context.Background() - if sessionAuth, err := m.credsMgr.GetCredentialValue(ctx, "AUGMENT_SESSION_AUTH"); err == nil && sessionAuth != "" { - env["AUGMENT_SESSION_AUTH"] = sessionAuth + for _, credKey := range agentConfig.RequiredEnv { + if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { + env[credKey] = value + } } } @@ -784,476 +670,158 @@ func (m *Manager) getOrCreateWorktree(ctx context.Context, req *LaunchRequest) ( return wt, nil } -// initializeAgent waits for agentctl to be ready and initializes the agent -func (m *Manager) initializeAgent(instance *AgentInstance, agentConfig *registry.AgentTypeConfig, taskDescription string) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) +// 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) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() m.logger.Info("waiting for agentctl to be ready", - zap.String("instance_id", instance.ID), - zap.String("url", instance.agentctl.BaseURL())) + zap.String("execution_id", execution.ID), + zap.String("url", execution.agentctl.BaseURL())) - // Wait for agentctl HTTP server to be ready - if err := instance.agentctl.WaitForReady(ctx, 60*time.Second); err != nil { + if err := execution.agentctl.WaitForReady(ctx, 60*time.Second); err != nil { m.logger.Error("agentctl not ready", - zap.String("instance_id", instance.ID), + zap.String("execution_id", execution.ID), zap.Error(err)) - m.updateInstanceError(instance.ID, "agentctl not ready: "+err.Error()) + m.updateExecutionError(execution.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 using configuration-driven SessionManager - if err := m.initializeACPSession(ctx, instance, agentConfig, 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 - } + m.logger.Info("agentctl ready - shell/workspace access available", + zap.String("execution_id", execution.ID)) } -// initializeACPSession sends the ACP initialization messages using the SessionManager -// The agentConfig is used for configuration-driven session resumption behavior -func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInstance, agentConfig *registry.AgentTypeConfig, taskDescription string) error { - m.logger.Info("initializing ACP session", - zap.String("instance_id", instance.ID), - zap.String("agentctl_url", instance.agentctl.BaseURL()), - zap.String("agent_type", agentConfig.ID)) - // Use SessionManager for configuration-driven session initialization - // This replaces the hardcoded auggie-specific logic with registry-based configuration - result, err := m.sessionManager.InitializeSession( - ctx, - instance.agentctl, - agentConfig, - instance.ACPSessionID, // Existing session ID to resume (if any) - instance.WorkspacePath, - ) + +// 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) (*registry.AgentTypeConfig, 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 { - m.logger.Error("session initialization failed", - zap.String("instance_id", instance.ID), - zap.Error(err)) - return err - } - - m.logger.Info("ACP session initialized", - zap.String("instance_id", instance.ID), - zap.String("agent_name", result.AgentName), - zap.String("agent_version", result.AgentVersion), - zap.String("session_id", result.SessionID)) - - instance.ACPSessionID = result.SessionID - m.publishACPSessionCreated(instance, result.SessionID) - - // Set up WebSocket streams using StreamManager - // Use a ready channel to signal when the updates stream is connected - updatesReady := make(chan struct{}) - m.streamManager.ConnectAll(instance, updatesReady) - - // Note: Shell stream is started on-demand when a client sends shell.subscribe - // This ensures the buffered output (including initial prompt) is received - // when a client is actually listening - - // Wait for the updates stream to connect before sending prompt - // This prevents race conditions where notifications are sent before streams are ready - select { - case <-updatesReady: - m.logger.Debug("updates stream ready") - case <-time.After(5 * time.Second): - m.logger.Warn("timeout waiting for updates stream to connect, proceeding anyway") - } - - // 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", instance.ACPSessionID), - zap.String("task_description", taskDescription)) - - // Clear buffers before starting prompt - instance.messageMu.Lock() - instance.messageBuffer.Reset() - instance.reasoningBuffer.Reset() - instance.summaryBuffer.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 content from buffers - instance.messageMu.Lock() - agentMessage := instance.messageBuffer.String() - instance.messageBuffer.Reset() - instance.reasoningBuffer.Reset() - instance.summaryBuffer.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. -// If ready is non-nil, it will be closed when the stream connection is established (or fails). -func (m *Manager) handleUpdatesStream(instance *AgentInstance, ready chan<- struct{}) { - ctx := context.Background() - - err := instance.agentctl.StreamUpdates(ctx, func(update agentctl.SessionUpdate) { - m.handleSessionUpdate(instance, update) - }) - - // Signal that the stream connection attempt is complete (success or failure) - // StreamUpdates returns immediately after establishing the WebSocket connection - // and starting the read goroutine, so this signals that we're ready to receive updates - if ready != nil { - close(ready) + return nil, fmt.Errorf("failed to resolve profile: %w", err) } + // Map agent name to registry ID (e.g., "auggie" -> "auggie-agent") + agentTypeName := profileInfo.AgentName + "-agent" + agentConfig, err := m.registry.GetAgentType(agentTypeName) if err != nil { - m.logger.Error("failed to connect to updates stream", - zap.String("instance_id", instance.ID), - zap.Error(err)) + return nil, fmt.Errorf("agent type not found: %s", agentTypeName) } + + return agentConfig, nil } -// 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, nil) - go m.handlePermissionStream(instance) - go m.handleGitStatusStream(instance) - go m.handleFileChangesStream(instance) - // Note: Shell stream is started on-demand via shell.subscribe - - // 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)) +// initializeACPSession delegates to SessionManager for full ACP session initialization and prompting +func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig *registry.AgentTypeConfig, taskDescription string) error { + return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, m.MarkReady) } // handlePermissionNotification processes incoming permission requests from the agent -func (m *Manager) handlePermissionNotification(instance *AgentInstance, notification *agentctl.PermissionNotification) { +func (m *Manager) handlePermissionNotification(execution *AgentExecution, notification *agentctl.PermissionNotification) { m.logger.Info("received permission request", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), + zap.String("execution_id", execution.ID), + zap.String("task_id", execution.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)) - } + m.eventPublisher.PublishPermissionRequest(execution, notification) } // handleSessionUpdate processes incoming session updates from the agent -func (m *Manager) handleSessionUpdate(instance *AgentInstance, update agentctl.SessionUpdate) { +func (m *Manager) handleSessionUpdate(execution *AgentExecution, update agentctl.SessionUpdate) { // Handle different update types based on the Type field switch update.Type { case "message_chunk": - m.updateInstanceProgress(instance.ID, 50) + m.updateExecutionProgress(execution.ID, 50) // Accumulate message content for saving as comment when a step completes if update.Text != "" { - instance.messageMu.Lock() - instance.messageBuffer.WriteString(update.Text) - instance.messageMu.Unlock() + execution.messageMu.Lock() + execution.messageBuffer.WriteString(update.Text) + execution.messageMu.Unlock() } case "reasoning": // Accumulate reasoning/thinking content - instance.messageMu.Lock() + execution.messageMu.Lock() if update.ReasoningText != "" { - instance.reasoningBuffer.WriteString(update.ReasoningText) + execution.reasoningBuffer.WriteString(update.ReasoningText) } if update.ReasoningSummary != "" { - instance.summaryBuffer.WriteString(update.ReasoningSummary) + execution.summaryBuffer.WriteString(update.ReasoningSummary) } - instance.messageMu.Unlock() + execution.messageMu.Unlock() case "tool_call": // 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) + m.flushMessageBufferAsComment(execution) m.logger.Info("tool call started", - zap.String("instance_id", instance.ID), + zap.String("execution_id", execution.ID), zap.String("tool_call_id", update.ToolCallID), zap.String("title", update.ToolTitle), zap.String("tool_name", update.ToolName)) - m.updateInstanceProgress(instance.ID, 60) + m.updateExecutionProgress(execution.ID, 60) // Publish tool call as a comment so it appears in the chat - m.publishToolCall(instance, update.ToolCallID, update.ToolTitle, update.ToolStatus, update.ToolArgs) + m.eventPublisher.PublishToolCall(execution, update.ToolCallID, update.ToolTitle, update.ToolStatus, update.ToolArgs) case "tool_update": // Check if tool call completed switch update.ToolStatus { case "complete", "completed": - m.updateInstanceProgress(instance.ID, 80) - m.publishToolCallCompleteFromUpdate(instance, update) + m.updateExecutionProgress(execution.ID, 80) + m.eventPublisher.PublishToolCallComplete(execution, update) case "error", "failed": - m.publishToolCallCompleteFromUpdate(instance, update) + m.eventPublisher.PublishToolCallComplete(execution, update) } case "plan": m.logger.Info("agent plan update", - zap.String("instance_id", instance.ID)) + zap.String("execution_id", execution.ID)) case "error": m.logger.Error("agent error", - zap.String("instance_id", instance.ID), + zap.String("execution_id", execution.ID), zap.String("error", update.Error)) case "complete": m.logger.Info("agent turn complete", - zap.String("instance_id", instance.ID), + zap.String("execution_id", execution.ID), zap.String("session_id", update.SessionID)) // Flush accumulated message buffer as a comment - m.flushMessageBufferAsComment(instance) + m.flushMessageBufferAsComment(execution) // Mark agent as READY for follow-up prompts - if err := m.MarkReady(instance.ID); err != nil { - m.logger.Error("failed to mark instance as ready after complete", - zap.String("instance_id", instance.ID), + 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)) } } // Publish session update to event bus for WebSocket streaming - m.publishSessionUpdate(instance, update) -} - -// publishSessionUpdate publishes a session update to the event bus -func (m *Manager) publishSessionUpdate(instance *AgentInstance, update agentctl.SessionUpdate) { - if m.eventBus == nil { - return - } - - // Build the update data - our SessionUpdate type marshals cleanly - updateData := map[string]interface{}{ - "type": update.Type, - } - - if update.SessionID != "" { - updateData["session_id"] = update.SessionID - } - if update.Text != "" { - updateData["text"] = update.Text - } - if update.ToolCallID != "" { - updateData["tool_call_id"] = update.ToolCallID - } - if update.ToolName != "" { - updateData["tool_name"] = update.ToolName - } - if update.ToolTitle != "" { - updateData["tool_title"] = update.ToolTitle - } - if update.ToolStatus != "" { - updateData["tool_status"] = update.ToolStatus - } - if update.ToolArgs != nil { - updateData["tool_args"] = update.ToolArgs - } - if update.ToolResult != nil { - updateData["tool_result"] = update.ToolResult - } - if update.Error != "" { - updateData["error"] = update.Error - } - if update.Data != nil { - updateData["data"] = update.Data - } - - // Build ACP message data - data := map[string]interface{}{ - "type": "session/update", - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "agent_id": instance.ID, - "task_id": instance.TaskID, - "session_id": update.SessionID, - "data": updateData, - } - - 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 update", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID), - zap.Error(err)) - } + m.eventPublisher.PublishSessionUpdate(execution, update) } // 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{}{ +func (m *Manager) handleGitStatusUpdate(execution *AgentExecution, update *agentctl.GitStatusUpdate) { + // Store git status in execution metadata + m.executionStore.UpdateMetadata(execution.ID, func(metadata map[string]interface{}) map[string]interface{} { + metadata["git_status"] = map[string]interface{}{ "branch": update.Branch, "remote_branch": update.RemoteBranch, "modified": update.Modified, @@ -1265,155 +833,30 @@ func (m *Manager) handleGitStatusUpdate(instance *AgentInstance, update *agentct "behind": update.Behind, "timestamp": update.Timestamp, } - } - m.mu.Unlock() + return metadata + }) // 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)) + m.eventPublisher.PublishGitStatus(execution, update) } // handleFileChangeNotification processes file change notifications from the workspace tracker -func (m *Manager) handleFileChangeNotification(instance *AgentInstance, notification *agentctl.FileChangeNotification) { - m.publishFileChange(instance, notification) -} - -// 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, reasoning, summary 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, - } - if reasoning != "" { - data["reasoning"] = reasoning - } - if summary != "" { - data["summary"] = summary - } - - 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)) - } +func (m *Manager) handleFileChangeNotification(execution *AgentExecution, notification *agentctl.FileChangeNotification) { + m.eventPublisher.PublishFileChange(execution, notification) } // 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() - reasoning := instance.reasoningBuffer.String() - summary := instance.summaryBuffer.String() - instance.messageBuffer.Reset() - instance.reasoningBuffer.Reset() - instance.summaryBuffer.Reset() - instance.messageMu.Unlock() +func (m *Manager) flushMessageBufferAsComment(execution *AgentExecution) { + execution.messageMu.Lock() + agentMessage := execution.messageBuffer.String() + reasoning := execution.reasoningBuffer.String() + summary := execution.summaryBuffer.String() + execution.messageBuffer.Reset() + execution.reasoningBuffer.Reset() + execution.summaryBuffer.Reset() + execution.messageMu.Unlock() // Only publish if there's actual content (ignore whitespace-only) trimmed := strings.TrimSpace(agentMessage) @@ -1422,486 +865,131 @@ func (m *Manager) flushMessageBufferAsComment(instance *AgentInstance) { } // Reuse the prompt_complete event type - the orchestrator handles it the same way - m.publishPromptComplete(instance, agentMessage, reasoning, summary) + m.eventPublisher.PublishPromptComplete(execution, agentMessage, reasoning, summary) } -// 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)) - } +// updateExecutionProgress updates an execution's progress +func (m *Manager) updateExecutionProgress(executionID string, progress int) { + m.executionStore.UpdateProgress(executionID, progress) } -// publishToolCallCompleteFromUpdate publishes a tool call completion event from a SessionUpdate -func (m *Manager) publishToolCallCompleteFromUpdate(instance *AgentInstance, update agentctl.SessionUpdate) { - if m.eventBus == nil { - return - } - - 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": update.ToolCallID, - "status": update.ToolStatus, - } - - 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 -} - -// 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), - ) - - // Pass protocol to agentctl inside the container - if agentConfig.Protocol != "" { - env = append(env, fmt.Sprintf("AGENTCTL_PROTOCOL=%s", agentConfig.Protocol)) - } - - // 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 - ) - - // 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 using CommandBuilder (configuration-driven, no hardcoded agent names) - model := "" - if profileInfo != nil { - model = profileInfo.Model - } - cmdOpts := CommandOptions{ - Model: model, - SessionID: req.ACPSessionID, // CommandBuilder handles resume flag based on SessionConfig - } - cmd := m.commandBuilder.BuildCommand(agentConfig, cmdOpts) - - 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, 0o755) - result = strings.ReplaceAll(result, "{augment_sessions}", augmentSessionsDir) - } - - return result +// 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 -// 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 +func (m *Manager) PromptAgent(ctx context.Context, executionID string, prompt string) (*PromptResult, error) { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return nil, fmt.Errorf("execution %q not found", executionID) + } + return m.sessionManager.SendPrompt(ctx, execution, prompt, true, m.MarkReady) } -// 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] +// 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 { - m.mu.Unlock() - return nil, fmt.Errorf("instance %q not found", instanceID) + return fmt.Errorf("execution %q not found", executionID) } - if instance.agentctl == nil { - m.mu.Unlock() - return nil, fmt.Errorf("instance %q has no agentctl client", instanceID) + runtimeName := "unknown" + if m.runtime != nil { + runtimeName = m.runtime.Name() } - - // 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 buffers before starting new prompt - instance.messageMu.Lock() - instance.messageBuffer.Reset() - instance.reasoningBuffer.Reset() - instance.summaryBuffer.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 content from buffers - instance.messageMu.Lock() - agentMessage := instance.messageBuffer.String() - instance.messageBuffer.Reset() - instance.reasoningBuffer.Reset() - instance.summaryBuffer.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.String("execution_id", executionID), zap.Bool("force", force), - zap.String("runtime", m.agentCfg.Runtime)) + zap.String("runtime", runtimeName)) // Try to gracefully stop via agentctl first - if instance.agentctl != nil && !force { - if err := instance.agentctl.Stop(ctx); err != nil { + if execution.agentctl != nil && !force { + if err := execution.agentctl.Stop(ctx); err != nil { m.logger.Warn("failed to stop agent via agentctl", - zap.String("instance_id", instanceID), + zap.String("execution_id", executionID), zap.Error(err)) } - instance.agentctl.Close() + execution.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) + // Stop the agent execution via runtime + if m.runtime != nil { + runtimeInstance := &RuntimeInstance{ + InstanceID: execution.ID, + TaskID: execution.TaskID, + ContainerID: execution.ContainerID, + StandaloneInstanceID: execution.standaloneInstanceID, + StandalonePort: execution.standalonePort, + } + if err := m.runtime.StopInstance(ctx, runtimeInstance, force); err != nil { + return err + } } - 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() + // 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("instance_id", instanceID), - zap.String("task_id", instance.TaskID)) + zap.String("execution_id", executionID), + zap.String("task_id", execution.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) - } + m.eventPublisher.PublishAgentEvent(ctx, events.AgentStopped, execution) 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() - + execution, exists := m.executionStore.GetByTaskID(taskID) if !exists { return fmt.Errorf("no agent running for task %q", taskID) } - return m.StopAgent(ctx, instanceID, force) + return m.StopAgent(ctx, execution.ID, 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 +// GetExecution returns an agent execution by ID +func (m *Manager) GetExecution(executionID string) (*AgentExecution, bool) { + return m.executionStore.Get(executionID) } -// 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 +// GetExecutionByTaskID returns the agent execution for a task +func (m *Manager) GetExecutionByTaskID(taskID string) (*AgentExecution, bool) { + return m.executionStore.GetByTaskID(taskID) } -// 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 +// GetExecutionByContainerID returns the agent execution for a container +func (m *Manager) GetExecutionByContainerID(containerID string) (*AgentExecution, bool) { + return m.executionStore.GetByContainerID(containerID) } -// 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 +// ListExecutions returns all active executions +func (m *Manager) ListExecutions() []*AgentExecution { + return m.executionStore.List() } // 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) + // First check if we have an execution tracked for this task + execution, exists := m.GetExecutionByTaskID(taskID) if !exists { return false } // Probe agentctl status to verify the agent process is running - if instance.agentctl == nil { + if execution.agentctl == nil { return false } - status, err := instance.agentctl.GetStatus(ctx) + status, err := execution.agentctl.GetStatus(ctx) if err != nil { m.logger.Debug("failed to get agentctl status", zap.String("task_id", taskID), @@ -1912,180 +1000,118 @@ func (m *Manager) IsAgentRunningForTask(ctx context.Context, taskID string) bool 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) +// UpdateStatus updates the status of an execution +func (m *Manager) UpdateStatus(executionID string, status v1.AgentStatus) error { + if !m.executionStore.WithLock(executionID, func(execution *AgentExecution) { + execution.Status = status + }) { + return fmt.Errorf("execution %q not found", executionID) } - instance.Status = status - m.logger.Debug("updated instance status", - zap.String("instance_id", instanceID), + m.logger.Debug("updated execution status", + zap.String("execution_id", executionID), 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) +// UpdateProgress updates the progress of an execution +func (m *Manager) UpdateProgress(executionID string, progress int) error { + if !m.executionStore.WithLock(executionID, func(execution *AgentExecution) { + execution.Progress = progress + }) { + return fmt.Errorf("execution %q not found", executionID) } - instance.Progress = progress - m.logger.Debug("updated instance progress", - zap.String("instance_id", instanceID), + m.logger.Debug("updated execution progress", + zap.String("execution_id", executionID), 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] +// MarkReady marks an execution as ready for follow-up prompts +func (m *Manager) MarkReady(executionID string) error { + execution, exists := m.executionStore.Get(executionID) if !exists { - m.mu.Unlock() - return fmt.Errorf("instance %q not found", instanceID) + return fmt.Errorf("execution %q not found", executionID) } - instance.Status = v1.AgentStatusReady - m.mu.Unlock() + m.executionStore.UpdateStatus(executionID, v1.AgentStatusReady) - m.logger.Info("instance ready for follow-up prompts", - zap.String("instance_id", instanceID)) + m.logger.Info("execution ready for follow-up prompts", + zap.String("execution_id", executionID)) // Publish ready event - m.publishEvent(context.Background(), events.AgentReady, instance) + m.eventPublisher.PublishAgentEvent(context.Background(), events.AgentReady, execution) 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] +// MarkCompleted marks an execution as completed +func (m *Manager) MarkCompleted(executionID string, exitCode int, errorMessage string) error { + execution, exists := m.executionStore.Get(executionID) if !exists { - m.mu.Unlock() - return fmt.Errorf("instance %q not found", instanceID) + return fmt.Errorf("execution %q not found", executionID) } - now := time.Now() - instance.FinishedAt = &now - instance.ExitCode = &exitCode - instance.ErrorMessage = errorMessage + m.executionStore.WithLock(executionID, func(exec *AgentExecution) { + now := time.Now() + exec.FinishedAt = &now + exec.ExitCode = &exitCode + exec.ErrorMessage = errorMessage - if exitCode == 0 && errorMessage == "" { - instance.Status = v1.AgentStatusCompleted - instance.Progress = 100 - } else { - instance.Status = v1.AgentStatusFailed - } - m.mu.Unlock() + if exitCode == 0 && errorMessage == "" { + exec.Status = v1.AgentStatusCompleted + exec.Progress = 100 + } else { + exec.Status = v1.AgentStatusFailed + } + }) - m.logger.Info("instance completed", - zap.String("instance_id", instanceID), + m.logger.Info("execution completed", + zap.String("execution_id", executionID), zap.Int("exit_code", exitCode), - zap.String("status", string(instance.Status))) + zap.String("status", string(execution.Status))) // Publish completion event eventType := events.AgentCompleted - if instance.Status == v1.AgentStatusFailed { + if execution.Status == v1.AgentStatusFailed { eventType = events.AgentFailed } - m.publishEvent(context.Background(), eventType, instance) + m.eventPublisher.PublishAgentEvent(context.Background(), eventType, execution) return nil } -// RemoveInstance removes a completed instance from tracking -func (m *Manager) RemoveInstance(instanceID string) { - m.mu.Lock() - defer m.mu.Unlock() +// RemoveExecution removes a completed execution from tracking +func (m *Manager) RemoveExecution(executionID string) { + m.executionStore.Remove(executionID) + m.logger.Debug("removed execution from tracking", + zap.String("execution_id", executionID)) +} - instance, exists := m.instances[instanceID] +// CleanupStaleExecutionByTaskID removes a stale agent execution from tracking without trying to stop it. +// This is used when we detect the agent process has stopped but the execution is still tracked. +func (m *Manager) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { + execution, exists := m.executionStore.GetByTaskID(taskID) if !exists { - return + return nil // No execution to clean up } - delete(m.instances, instanceID) - delete(m.byTask, instance.TaskID) - delete(m.byContainer, instance.ContainerID) + m.logger.Info("cleaning up stale agent execution", + zap.String("task_id", taskID), + zap.String("execution_id", execution.ID)) - 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 + // Close agentctl connection if it exists + if execution.agentctl != nil { + execution.agentctl.Close() } - 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, - } + // Remove from execution store + m.executionStore.Remove(execution.ID) - 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)) - } -} - -func (m *Manager) publishACPSessionCreated(instance *AgentInstance, sessionID string) { - if m.eventBus == nil || sessionID == "" { - return - } - - data := map[string]interface{}{ - "task_id": instance.TaskID, - "agent_instance_id": instance.ID, - "acp_session_id": sessionID, - } - - event := bus.NewEvent(events.AgentACPSessionCreated, "agent-manager", data) - if err := m.eventBus.Publish(context.Background(), events.AgentACPSessionCreated, event); err != nil { - m.logger.Error("failed to publish ACP session event", - zap.String("event_type", events.AgentACPSessionCreated), - zap.String("instance_id", instance.ID), - zap.Error(err)) - } else { - m.logger.Info("published ACP session event", - zap.String("event_type", events.AgentACPSessionCreated), - zap.String("task_id", instance.TaskID), - zap.String("agent_instance_id", instance.ID), - zap.String("acp_session_id", sessionID)) - } + return nil } // cleanupLoop runs periodic cleanup of stale containers @@ -2113,16 +1139,14 @@ func (m *Manager) cleanupLoop(ctx context.Context) { func (m *Manager) performCleanup(ctx context.Context) { m.logger.Debug("running cleanup check") - // Skip cleanup if docker client is not available (e.g., in tests) - if m.docker == nil { - m.logger.Debug("skipping cleanup - no docker client") + // 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.docker.ListContainers(ctx, map[string]string{ - "kandev.managed": "true", - }) + containers, err := m.containerManager.ListManagedContainers(ctx) if err != nil { m.logger.Error("failed to list containers for cleanup", zap.Error(err)) return @@ -2131,13 +1155,10 @@ func (m *Manager) performCleanup(ctx context.Context) { 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() - + execution, tracked := m.executionStore.GetByContainerID(container.ID) if tracked { // Get container info to get exit code - info, err := m.docker.GetContainerInfo(ctx, container.ID) + 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), @@ -2145,43 +1166,40 @@ func (m *Manager) performCleanup(ctx context.Context) { continue } - // Mark instance as completed + // Mark execution as completed errorMsg := "" if info.ExitCode != 0 { errorMsg = fmt.Sprintf("container exited with code %d", info.ExitCode) } - _ = m.MarkCompleted(instanceID, info.ExitCode, errorMsg) + _ = m.MarkCompleted(execution.ID, info.ExitCode, errorMsg) // Remove the container - if err := m.docker.RemoveContainer(ctx, container.ID, false); err != nil { + 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 instance from tracking so new agents can be launched - m.RemoveInstance(instanceID) + // Remove the execution from tracking so new agents can be launched + m.RemoveExecution(execution.ID) } } } } -// 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() - +// RespondToPermission sends a response to a permission request for an agent execution +func (m *Manager) RespondToPermission(executionID, pendingID, optionID string, cancelled bool) error { + execution, exists := m.executionStore.Get(executionID) if !exists { - return fmt.Errorf("agent instance not found: %s", instanceID) + return fmt.Errorf("agent execution not found: %s", executionID) } - if instance.agentctl == nil { - return fmt.Errorf("agent instance has no agentctl client: %s", instanceID) + if execution.agentctl == nil { + return fmt.Errorf("agent execution has no agentctl client: %s", executionID) } m.logger.Info("responding to permission request", - zap.String("instance_id", instanceID), + zap.String("execution_id", executionID), zap.String("pending_id", pendingID), zap.String("option_id", optionID), zap.Bool("cancelled", cancelled)) @@ -2189,25 +1207,15 @@ func (m *Manager) RespondToPermission(instanceID, pendingID, optionID string, ca ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - return instance.agentctl.RespondToPermission(ctx, pendingID, optionID, cancelled) + return execution.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) + execution, exists := m.executionStore.GetByTaskID(taskID) + if !exists { + return fmt.Errorf("no agent execution found for task: %s", taskID) } - return m.RespondToPermission(instance.ID, pendingID, optionID, cancelled) + return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled) } diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index 313517c4c7..af522585f6 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -9,21 +9,11 @@ import ( "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" v1 "github.com/kandev/kandev/pkg/api/v1" ) -// testAgentConfig returns a default AgentConfig for testing (docker mode) -func testAgentConfig() config.AgentConfig { - return config.AgentConfig{ - Runtime: "docker", - StandaloneHost: "localhost", - StandalonePort: 9999, - } -} - // MockDockerClient implements a mock for the docker.Client for testing type MockDockerClient struct { CreateContainerFn func(ctx context.Context, cfg docker.ContainerConfig) (string, error) @@ -135,30 +125,54 @@ func newTestRegistry() *registry.Registry { return reg } -func TestNewManager(t *testing.T) { +// MockCredentialsManager implements CredentialsManager for testing +type MockCredentialsManager struct{} + +func (m *MockCredentialsManager) GetCredentialValue(ctx context.Context, key string) (string, error) { + return "", nil +} + +// MockProfileResolver implements ProfileResolver for testing +type MockProfileResolver struct{} + +func (m *MockProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { + return &AgentProfileInfo{ + ProfileID: profileID, + ProfileName: "Test Profile", + AgentID: "augment-agent", + AgentName: "auggie", + Model: "claude-sonnet-4-20250514", + }, nil +} + +// newTestManager creates a Manager for testing with mock dependencies +func newTestManager() *Manager { log := newTestLogger() reg := newTestRegistry() eventBus := &MockEventBus{} + credsMgr := &MockCredentialsManager{} + profileResolver := &MockProfileResolver{} + // Pass nil for runtime and containerManager - tests don't need them + return NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, log) +} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) +func TestNewManager(t *testing.T) { + mgr := newTestManager() if mgr == nil { t.Fatal("expected non-nil manager") } - if len(mgr.instances) != 0 { - t.Errorf("expected empty instances, got %d", len(mgr.instances)) + if len(mgr.ListExecutions()) != 0 { + t.Errorf("expected empty executions, got %d", len(mgr.ListExecutions())) } } -func TestManager_GetInstance(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) +func TestManager_GetExecution(t *testing.T) { + mgr := newTestManager() - // Manually add an instance for testing - instance := &AgentInstance{ - ID: "test-instance-id", + // Manually add an execution for testing + execution := &AgentExecution{ + ID: "test-execution-id", TaskID: "test-task-id", AgentProfileID: "test-agent", ContainerID: "container-123", @@ -166,36 +180,29 @@ func TestManager_GetInstance(t *testing.T) { StartedAt: time.Now(), } - mgr.mu.Lock() - mgr.instances[instance.ID] = instance - mgr.byTask[instance.TaskID] = instance.ID - mgr.byContainer[instance.ContainerID] = instance.ID - mgr.mu.Unlock() + mgr.executionStore.Add(execution) - // Test GetInstance - got, found := mgr.GetInstance("test-instance-id") + // Test GetExecution + got, found := mgr.GetExecution("test-execution-id") if !found { - t.Fatal("expected to find instance") + t.Fatal("expected to find execution") } - if got.ID != instance.ID { - t.Errorf("expected ID %q, got %q", instance.ID, got.ID) + if got.ID != execution.ID { + t.Errorf("expected ID %q, got %q", execution.ID, got.ID) } // Test not found - _, found = mgr.GetInstance("non-existent") + _, found = mgr.GetExecution("non-existent") if found { - t.Error("expected not to find instance") + t.Error("expected not to find execution") } } -func TestManager_GetInstanceByTaskID(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) +func TestManager_GetExecutionByTaskID(t *testing.T) { + mgr := newTestManager() - instance := &AgentInstance{ - ID: "test-instance-id", + execution := &AgentExecution{ + ID: "test-execution-id", TaskID: "test-task-id", AgentProfileID: "test-agent", ContainerID: "container-123", @@ -203,35 +210,29 @@ func TestManager_GetInstanceByTaskID(t *testing.T) { StartedAt: time.Now(), } - mgr.mu.Lock() - mgr.instances[instance.ID] = instance - mgr.byTask[instance.TaskID] = instance.ID - mgr.mu.Unlock() + mgr.executionStore.Add(execution) - // Test GetInstanceByTaskID - got, found := mgr.GetInstanceByTaskID("test-task-id") + // Test GetExecutionByTaskID + got, found := mgr.GetExecutionByTaskID("test-task-id") if !found { - t.Fatal("expected to find instance") + t.Fatal("expected to find execution") } - if got.TaskID != instance.TaskID { - t.Errorf("expected TaskID %q, got %q", instance.TaskID, got.TaskID) + if got.TaskID != execution.TaskID { + t.Errorf("expected TaskID %q, got %q", execution.TaskID, got.TaskID) } // Test not found - _, found = mgr.GetInstanceByTaskID("non-existent") + _, found = mgr.GetExecutionByTaskID("non-existent") if found { - t.Error("expected not to find instance") + t.Error("expected not to find execution") } } -func TestManager_GetInstanceByContainerID(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) +func TestManager_GetExecutionByContainerID(t *testing.T) { + mgr := newTestManager() - instance := &AgentInstance{ - ID: "test-instance-id", + execution := &AgentExecution{ + ID: "test-execution-id", TaskID: "test-task-id", AgentProfileID: "test-agent", ContainerID: "container-123", @@ -239,74 +240,61 @@ func TestManager_GetInstanceByContainerID(t *testing.T) { StartedAt: time.Now(), } - mgr.mu.Lock() - mgr.instances[instance.ID] = instance - mgr.byContainer[instance.ContainerID] = instance.ID - mgr.mu.Unlock() + mgr.executionStore.Add(execution) - // Test GetInstanceByContainerID - got, found := mgr.GetInstanceByContainerID("container-123") + // Test GetExecutionByContainerID + got, found := mgr.GetExecutionByContainerID("container-123") if !found { - t.Fatal("expected to find instance") + t.Fatal("expected to find execution") } - if got.ContainerID != instance.ContainerID { - t.Errorf("expected ContainerID %q, got %q", instance.ContainerID, got.ContainerID) + if got.ContainerID != execution.ContainerID { + t.Errorf("expected ContainerID %q, got %q", execution.ContainerID, got.ContainerID) } // Test not found - _, found = mgr.GetInstanceByContainerID("non-existent") + _, found = mgr.GetExecutionByContainerID("non-existent") if found { - t.Error("expected not to find instance") + t.Error("expected not to find execution") } } -func TestManager_ListInstances(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) +func TestManager_ListExecutions(t *testing.T) { + mgr := newTestManager() // Empty list - list := mgr.ListInstances() + list := mgr.ListExecutions() if len(list) != 0 { t.Errorf("expected empty list, got %d", len(list)) } - // Add instances - mgr.mu.Lock() - mgr.instances["instance-1"] = &AgentInstance{ID: "instance-1", TaskID: "task-1", Status: v1.AgentStatusRunning} - mgr.instances["instance-2"] = &AgentInstance{ID: "instance-2", TaskID: "task-2", Status: v1.AgentStatusCompleted} - mgr.mu.Unlock() + // Add executions + mgr.executionStore.Add(&AgentExecution{ID: "execution-1", TaskID: "task-1", Status: v1.AgentStatusRunning}) + mgr.executionStore.Add(&AgentExecution{ID: "execution-2", TaskID: "task-2", Status: v1.AgentStatusCompleted}) - list = mgr.ListInstances() + list = mgr.ListExecutions() if len(list) != 2 { - t.Errorf("expected 2 instances, got %d", len(list)) + t.Errorf("expected 2 executions, got %d", len(list)) } } func TestManager_UpdateStatus(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) + mgr := newTestManager() - instance := &AgentInstance{ - ID: "test-instance-id", + execution := &AgentExecution{ + ID: "test-execution-id", TaskID: "test-task-id", Status: v1.AgentStatusRunning, } - mgr.mu.Lock() - mgr.instances[instance.ID] = instance - mgr.mu.Unlock() + mgr.executionStore.Add(execution) // Test UpdateStatus - err := mgr.UpdateStatus("test-instance-id", v1.AgentStatusCompleted) + err := mgr.UpdateStatus("test-execution-id", v1.AgentStatusCompleted) if err != nil { t.Fatalf("unexpected error: %v", err) } - got, _ := mgr.GetInstance("test-instance-id") + got, _ := mgr.GetExecution("test-execution-id") if got.Status != v1.AgentStatusCompleted { t.Errorf("expected status %v, got %v", v1.AgentStatusCompleted, got.Status) } @@ -314,34 +302,29 @@ func TestManager_UpdateStatus(t *testing.T) { // Test not found err = mgr.UpdateStatus("non-existent", v1.AgentStatusCompleted) if err == nil { - t.Error("expected error for non-existent instance") + t.Error("expected error for non-existent execution") } } func TestManager_UpdateProgress(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) + mgr := newTestManager() - instance := &AgentInstance{ - ID: "test-instance-id", + execution := &AgentExecution{ + ID: "test-execution-id", TaskID: "test-task-id", Status: v1.AgentStatusRunning, Progress: 0, } - mgr.mu.Lock() - mgr.instances[instance.ID] = instance - mgr.mu.Unlock() + mgr.executionStore.Add(execution) // Test UpdateProgress - err := mgr.UpdateProgress("test-instance-id", 50) + err := mgr.UpdateProgress("test-execution-id", 50) if err != nil { t.Fatalf("unexpected error: %v", err) } - got, _ := mgr.GetInstance("test-instance-id") + got, _ := mgr.GetExecution("test-execution-id") if got.Progress != 50 { t.Errorf("expected progress 50, got %d", got.Progress) } @@ -349,18 +332,15 @@ func TestManager_UpdateProgress(t *testing.T) { // Test not found err = mgr.UpdateProgress("non-existent", 50) if err == nil { - t.Error("expected error for non-existent instance") + t.Error("expected error for non-existent execution") } } func TestManager_MarkCompleted_Success(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) + mgr := newTestManager() - instance := &AgentInstance{ - ID: "test-instance-id", + execution := &AgentExecution{ + ID: "test-execution-id", TaskID: "test-task-id", AgentProfileID: "test-agent", ContainerID: "container-123", @@ -369,17 +349,15 @@ func TestManager_MarkCompleted_Success(t *testing.T) { Progress: 50, } - mgr.mu.Lock() - mgr.instances[instance.ID] = instance - mgr.mu.Unlock() + mgr.executionStore.Add(execution) // Mark as completed successfully (exit code 0) - err := mgr.MarkCompleted("test-instance-id", 0, "") + err := mgr.MarkCompleted("test-execution-id", 0, "") if err != nil { t.Fatalf("unexpected error: %v", err) } - got, _ := mgr.GetInstance("test-instance-id") + got, _ := mgr.GetExecution("test-execution-id") if got.Status != v1.AgentStatusCompleted { t.Errorf("expected status %v, got %v", v1.AgentStatusCompleted, got.Status) } @@ -395,13 +373,10 @@ func TestManager_MarkCompleted_Success(t *testing.T) { } func TestManager_MarkCompleted_Failure(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) + mgr := newTestManager() - instance := &AgentInstance{ - ID: "test-instance-id", + execution := &AgentExecution{ + ID: "test-execution-id", TaskID: "test-task-id", AgentProfileID: "test-agent", ContainerID: "container-123", @@ -409,17 +384,15 @@ func TestManager_MarkCompleted_Failure(t *testing.T) { StartedAt: time.Now(), } - mgr.mu.Lock() - mgr.instances[instance.ID] = instance - mgr.mu.Unlock() + mgr.executionStore.Add(execution) // Mark as failed - err := mgr.MarkCompleted("test-instance-id", 1, "process failed") + err := mgr.MarkCompleted("test-execution-id", 1, "process failed") if err != nil { t.Fatalf("unexpected error: %v", err) } - got, _ := mgr.GetInstance("test-instance-id") + got, _ := mgr.GetExecution("test-execution-id") if got.Status != v1.AgentStatusFailed { t.Errorf("expected status %v, got %v", v1.AgentStatusFailed, got.Status) } @@ -432,58 +405,45 @@ func TestManager_MarkCompleted_Failure(t *testing.T) { } func TestManager_MarkCompleted_NotFound(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) + mgr := newTestManager() err := mgr.MarkCompleted("non-existent", 0, "") if err == nil { - t.Error("expected error for non-existent instance") + t.Error("expected error for non-existent execution") } } -func TestManager_RemoveInstance(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) +func TestManager_RemoveExecution(t *testing.T) { + mgr := newTestManager() - instance := &AgentInstance{ - ID: "test-instance-id", + execution := &AgentExecution{ + ID: "test-execution-id", TaskID: "test-task-id", ContainerID: "container-123", } - mgr.mu.Lock() - mgr.instances[instance.ID] = instance - mgr.byTask[instance.TaskID] = instance.ID - mgr.byContainer[instance.ContainerID] = instance.ID - mgr.mu.Unlock() + mgr.executionStore.Add(execution) - // Remove instance - mgr.RemoveInstance("test-instance-id") + // Remove execution + mgr.RemoveExecution("test-execution-id") // Verify it's gone from all maps - if _, found := mgr.GetInstance("test-instance-id"); found { - t.Error("instance should be removed from instances map") + if _, found := mgr.GetExecution("test-execution-id"); found { + t.Error("execution should be removed from executions map") } - if _, found := mgr.GetInstanceByTaskID("test-task-id"); found { - t.Error("instance should be removed from byTask map") + if _, found := mgr.GetExecutionByTaskID("test-task-id"); found { + t.Error("execution should be removed from byTask map") } - if _, found := mgr.GetInstanceByContainerID("container-123"); found { - t.Error("instance should be removed from byContainer map") + if _, found := mgr.GetExecutionByContainerID("container-123"); found { + t.Error("execution should be removed from byContainer map") } // Remove non-existent should not panic - mgr.RemoveInstance("non-existent") + mgr.RemoveExecution("non-existent") } func TestManager_StartStop(t *testing.T) { - log := newTestLogger() - reg := newTestRegistry() - eventBus := &MockEventBus{} - mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log) + mgr := newTestManager() ctx := context.Background() diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/runtime.go new file mode 100644 index 0000000000..ddb0300b09 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/runtime.go @@ -0,0 +1,105 @@ +// Package lifecycle provides agent runtime abstractions. +package lifecycle + +import ( + "context" + "time" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agent/registry" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Runtime abstracts the agent execution environment (Docker, Standalone, K8s, SSH, etc.) +// Each runtime is responsible for creating and managing agentctl instances. +// Agent subprocess launching is handled separately via agentctl client methods. +type Runtime interface { + // Name returns the runtime identifier (e.g., "docker", "standalone", "k8s") + Name() string + + // HealthCheck verifies the runtime is available and operational + HealthCheck(ctx context.Context) error + + // CreateInstance creates a new agentctl instance for a task. + // This starts the agentctl process/container with workspace access (shell, git, files). + // Agent subprocess is NOT started - use agentctl.Client.ConfigureAgent() + Start(). + CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) + + // StopInstance stops an agentctl instance. + StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error + + // RecoverInstances discovers and recovers instances that were running before a restart. + // Returns recovered instances that can be re-tracked by the manager. + RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) +} + +// RuntimeCreateRequest contains parameters for creating an agentctl instance. +type RuntimeCreateRequest struct { + InstanceID string + TaskID string + AgentProfileID string + WorkspacePath string + Protocol string + Env map[string]string + Metadata map[string]interface{} + // Docker-specific + AgentConfig *registry.AgentTypeConfig + MainRepoGitDir string + WorktreeID string + WorktreeBranch string +} + +// RuntimeInstance represents an agentctl instance created by a runtime. +// This is returned by the runtime and contains enough info to build an AgentExecution. +type RuntimeInstance struct { + // Core identifiers + InstanceID string + TaskID string + + // Agentctl client for communicating with this instance + Client *agentctl.Client + + // Runtime-specific identifiers (only one set is populated) + ContainerID string // Docker + ContainerIP string // Docker + StandaloneInstanceID string // Standalone + StandalonePort int // Standalone + + // Common fields + WorkspacePath string + Metadata map[string]interface{} +} + +// ToAgentExecution converts a RuntimeInstance to an AgentExecution. +func (ri *RuntimeInstance) ToAgentExecution(req *RuntimeCreateRequest) *AgentExecution { + metadata := req.Metadata + if metadata == nil { + metadata = make(map[string]interface{}) + } + // Merge runtime metadata + for k, v := range ri.Metadata { + metadata[k] = v + } + + workspacePath := ri.WorkspacePath + if workspacePath == "" { + workspacePath = req.WorkspacePath + } + + return &AgentExecution{ + ID: ri.InstanceID, + TaskID: req.TaskID, + AgentProfileID: req.AgentProfileID, + ContainerID: ri.ContainerID, + ContainerIP: ri.ContainerIP, + WorkspacePath: workspacePath, + Status: v1.AgentStatusRunning, + StartedAt: time.Now(), + Progress: 0, + Metadata: metadata, + agentctl: ri.Client, + standaloneInstanceID: ri.StandaloneInstanceID, + standalonePort: ri.StandalonePort, + } +} + diff --git a/apps/backend/internal/agent/lifecycle/runtime_docker.go b/apps/backend/internal/agent/lifecycle/runtime_docker.go new file mode 100644 index 0000000000..73eee1f7da --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/runtime_docker.go @@ -0,0 +1,186 @@ +package lifecycle + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agent/docker" + "github.com/kandev/kandev/internal/common/logger" +) + +// DockerRuntime implements Runtime for Docker-based agent execution. +type DockerRuntime struct { + containerMgr *ContainerManager + docker *docker.Client + logger *logger.Logger +} + +// NewDockerRuntime creates a new Docker runtime. +func NewDockerRuntime(dockerClient *docker.Client, log *logger.Logger) *DockerRuntime { + return &DockerRuntime{ + containerMgr: NewContainerManager(dockerClient, "", log), + docker: dockerClient, + logger: log.WithFields(zap.String("runtime", "docker")), + } +} + +func (r *DockerRuntime) Name() string { + return "docker" +} + +func (r *DockerRuntime) HealthCheck(ctx context.Context) error { + // Check Docker is reachable by listing containers + _, err := r.docker.ListContainers(ctx, map[string]string{}) + if err != nil { + return fmt.Errorf("docker not reachable: %w", err) + } + return nil +} + +func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) { + // Convert RuntimeCreateRequest to ContainerConfig + containerCfg := ContainerConfig{ + AgentConfig: req.AgentConfig, + WorkspacePath: req.WorkspacePath, + TaskID: req.TaskID, + InstanceID: req.InstanceID, + MainRepoGitDir: req.MainRepoGitDir, + Credentials: req.Env, // Env contains credentials from the caller + } + + // Use ContainerManager to launch container + containerID, client, err := r.containerMgr.LaunchContainer(ctx, containerCfg) + if err != nil { + return nil, fmt.Errorf("failed to launch container: %w", err) + } + + // Get container IP for logging + containerIP, _ := r.docker.GetContainerIP(ctx, containerID) + + // Build metadata + metadata := make(map[string]interface{}) + if req.WorktreeID != "" { + metadata["worktree_id"] = req.WorktreeID + metadata["worktree_path"] = req.WorkspacePath + metadata["worktree_branch"] = req.WorktreeBranch + } + + r.logger.Info("docker instance created", + zap.String("instance_id", req.InstanceID), + zap.String("container_id", containerID), + zap.String("container_ip", containerIP)) + + return &RuntimeInstance{ + InstanceID: req.InstanceID, + TaskID: req.TaskID, + Client: client, + ContainerID: containerID, + ContainerIP: containerIP, + WorkspacePath: "/workspace", // Docker mounts workspace to /workspace + Metadata: metadata, + }, nil +} + +func (r *DockerRuntime) StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error { + if instance.ContainerID == "" { + return nil // No container to stop + } + + var err error + if force { + err = r.docker.KillContainer(ctx, instance.ContainerID, "SIGKILL") + } else { + err = r.docker.StopContainer(ctx, instance.ContainerID, 30*time.Second) + } + + if err != nil { + return fmt.Errorf("failed to stop container: %w", err) + } + + return nil +} + +func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) { + // Find containers with kandev.managed label + containers, err := r.docker.ListContainers(ctx, map[string]string{ + "kandev.managed": "true", + }) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + var recovered []*RuntimeInstance + for _, ctr := range containers { + // Only recover running containers + if ctr.State != "running" { + r.logger.Debug("skipping non-running container", + zap.String("container_id", ctr.ID), + zap.String("state", ctr.State)) + continue + } + + // Get container labels to extract instance info + labels, err := r.docker.GetContainerLabels(ctx, ctr.ID) + if err != nil { + r.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 == "" { + r.logger.Warn("container missing required labels", + zap.String("container_id", ctr.ID)) + continue + } + + // Get container IP + containerIP, err := r.docker.GetContainerIP(ctx, ctr.ID) + if err != nil { + r.logger.Warn("failed to get container IP", + zap.String("container_id", ctr.ID), + zap.Error(err)) + containerIP = "127.0.0.1" + } + + // Query the ControlServer to get the instance port + ctl := agentctl.NewStandaloneCtl(containerIP, AgentCtlPort, r.logger) + instanceInfo, err := ctl.GetInstance(ctx, instanceID) + if err != nil { + r.logger.Warn("failed to get instance info from container", + zap.String("container_id", ctr.ID), + zap.String("instance_id", instanceID), + zap.Error(err)) + continue + } + + client := agentctl.NewClient(containerIP, instanceInfo.Port, r.logger) + + recovered = append(recovered, &RuntimeInstance{ + InstanceID: instanceID, + TaskID: taskID, + Client: client, + ContainerID: ctr.ID, + ContainerIP: containerIP, + WorkspacePath: "/workspace", + Metadata: map[string]interface{}{"agent_profile_id": agentProfileID}, + }) + + r.logger.Info("recovered docker instance", + zap.String("instance_id", instanceID), + zap.String("task_id", taskID), + zap.String("container_id", ctr.ID), + zap.Int("instance_port", instanceInfo.Port)) + } + + return recovered, nil +} + diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go new file mode 100644 index 0000000000..5655412cfb --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -0,0 +1,108 @@ +package lifecycle + +import ( + "context" + "fmt" + + "go.uber.org/zap" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" +) + +// StandaloneRuntime implements Runtime for standalone agentctl execution. +// In this mode, a single agentctl control server manages multiple agent instances. +type StandaloneRuntime struct { + ctl *agentctl.StandaloneCtl + host string + port int + logger *logger.Logger +} + +// NewStandaloneRuntime creates a new standalone runtime. +func NewStandaloneRuntime(ctl *agentctl.StandaloneCtl, host string, port int, log *logger.Logger) *StandaloneRuntime { + return &StandaloneRuntime{ + ctl: ctl, + host: host, + port: port, + logger: log.WithFields(zap.String("runtime", "standalone")), + } +} + +func (r *StandaloneRuntime) Name() string { + return "standalone" +} + +func (r *StandaloneRuntime) HealthCheck(ctx context.Context) error { + return r.ctl.Health(ctx) +} + +func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) { + // Build environment variables + env := req.Env + if env == nil { + env = make(map[string]string) + } + env["KANDEV_TASK_ID"] = req.TaskID + + // Create instance via control API + // Agent command is NOT set - workspace access only. Agent is started explicitly via agentctl client. + createReq := &agentctl.CreateInstanceRequest{ + ID: req.InstanceID, + WorkspacePath: req.WorkspacePath, + AgentCommand: "", // Agent command set via Configure endpoint + Protocol: req.Protocol, + Env: env, + AutoStart: false, + } + + resp, err := r.ctl.CreateInstance(ctx, createReq) + if err != nil { + return nil, fmt.Errorf("failed to create standalone instance: %w", err) + } + + // Create agentctl client pointing to the instance port + client := agentctl.NewClient(r.host, resp.Port, r.logger) + + // Build metadata + metadata := make(map[string]interface{}) + metadata["standalone_port"] = resp.Port + if req.WorktreeID != "" { + metadata["worktree_id"] = req.WorktreeID + metadata["worktree_path"] = req.WorkspacePath + metadata["worktree_branch"] = req.WorktreeBranch + } + + r.logger.Info("standalone instance created", + zap.String("instance_id", req.InstanceID), + zap.Int("port", resp.Port), + zap.String("workspace", req.WorkspacePath)) + + return &RuntimeInstance{ + InstanceID: req.InstanceID, + TaskID: req.TaskID, + Client: client, + StandaloneInstanceID: resp.ID, + StandalonePort: resp.Port, + WorkspacePath: req.WorkspacePath, + Metadata: metadata, + }, nil +} + +func (r *StandaloneRuntime) StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error { + if instance.StandaloneInstanceID == "" { + return nil // No standalone instance to stop + } + + if err := r.ctl.DeleteInstance(ctx, instance.StandaloneInstanceID); err != nil { + return fmt.Errorf("failed to stop standalone instance: %w", err) + } + + return nil +} + +func (r *StandaloneRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) { + // Standalone instances are not persisted - they are transient processes + // managed by agentctl. Session resume will restart them as needed. + return nil, nil +} diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index ac641cdc4b..0416a40914 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -3,17 +3,22 @@ package lifecycle import ( "context" "fmt" + "time" "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/agentctl" + agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/common/logger" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // SessionManager handles ACP session initialization and management type SessionManager struct { - logger *logger.Logger + logger *logger.Logger + eventPublisher *EventPublisher + streamManager *StreamManager + executionStore *ExecutionStore } // NewSessionManager creates a new SessionManager @@ -23,6 +28,14 @@ func NewSessionManager(log *logger.Logger) *SessionManager { } } +// SetDependencies sets the optional dependencies for full session orchestration. +// These are set after construction to avoid circular dependencies. +func (sm *SessionManager) SetDependencies(ep *EventPublisher, strm *StreamManager, store *ExecutionStore) { + sm.eventPublisher = ep + sm.streamManager = strm + sm.executionStore = store +} + // InitializeResult contains the result of session initialization type InitializeResult struct { AgentName string @@ -167,3 +180,170 @@ func (sm *SessionManager) createNewSession( return sessionID, nil } + +// InitializeAndPrompt performs full ACP session initialization and sends the initial prompt. +// This orchestrates: +// 1. Session initialization (initialize + session/new or session/load) +// 2. Publishing ACP session created event +// 3. Connecting WebSocket streams +// 4. Sending the initial task prompt (if provided) +// 5. Marking the execution as ready +// +// Returns the session ID on success. +func (sm *SessionManager) InitializeAndPrompt( + ctx context.Context, + execution *AgentExecution, + agentConfig *registry.AgentTypeConfig, + taskDescription string, + markReady func(executionID string) error, +) error { + sm.logger.Info("initializing ACP session", + zap.String("execution_id", execution.ID), + zap.String("agentctl_url", execution.agentctl.BaseURL()), + zap.String("agent_type", agentConfig.ID), + zap.String("existing_acp_session_id", execution.ACPSessionID), + zap.Bool("resume_via_acp", agentConfig.SessionConfig.ResumeViaACP)) + + // Use InitializeSession for configuration-driven session initialization + result, err := sm.InitializeSession( + ctx, + execution.agentctl, + agentConfig, + execution.ACPSessionID, + execution.WorkspacePath, + ) + if err != nil { + sm.logger.Error("session initialization failed", + zap.String("execution_id", execution.ID), + zap.Error(err)) + return err + } + + sm.logger.Info("ACP session initialized", + zap.String("execution_id", execution.ID), + zap.String("agent_name", result.AgentName), + zap.String("agent_version", result.AgentVersion), + zap.String("session_id", result.SessionID)) + + execution.ACPSessionID = result.SessionID + + // Publish session created event + if sm.eventPublisher != nil { + sm.eventPublisher.PublishACPSessionCreated(execution, result.SessionID) + } + + // Set up WebSocket streams + if sm.streamManager != nil { + updatesReady := make(chan struct{}) + sm.streamManager.ConnectAll(execution, updatesReady) + + // Wait for the updates stream to connect before sending prompt + select { + case <-updatesReady: + sm.logger.Debug("updates stream ready") + case <-time.After(5 * time.Second): + sm.logger.Warn("timeout waiting for updates stream to connect, proceeding anyway") + } + } + + // Send the task prompt if provided + if taskDescription != "" { + // Use a long timeout for initial prompts + promptCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + _, err := sm.SendPrompt(promptCtx, execution, taskDescription, false, markReady) + if err != nil { + return err + } + } else { + sm.logger.Warn("no task description provided, marking as ready", + zap.String("execution_id", execution.ID)) + if err := markReady(execution.ID); err != nil { + sm.logger.Error("failed to mark execution as ready", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } + } + + return nil +} + +// SendPrompt sends a prompt to an agent execution and waits for completion. +// For initial prompts, pass validateStatus=false. For follow-up prompts, pass validateStatus=true. +// Returns the prompt result containing the stop reason and agent message. +func (sm *SessionManager) SendPrompt( + ctx context.Context, + execution *AgentExecution, + prompt string, + validateStatus bool, + markReady func(executionID string) error, +) (*PromptResult, error) { + if execution.agentctl == nil { + return nil, fmt.Errorf("execution %q has no agentctl client", execution.ID) + } + + // For follow-up prompts, validate status and update to RUNNING + if validateStatus { + if execution.Status != v1.AgentStatusRunning && execution.Status != v1.AgentStatusReady { + return nil, fmt.Errorf("execution %q is not ready for prompts (status: %s)", execution.ID, execution.Status) + } + if sm.executionStore != nil { + sm.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning) + } + } + + // Clear buffers before starting prompt + execution.messageMu.Lock() + execution.messageBuffer.Reset() + execution.reasoningBuffer.Reset() + execution.summaryBuffer.Reset() + execution.messageMu.Unlock() + + sm.logger.Info("sending prompt to agent", + zap.String("execution_id", execution.ID), + zap.Int("prompt_length", len(prompt))) + + // Prompt is synchronous - blocks until agent completes + resp, err := execution.agentctl.Prompt(ctx, prompt) + if err != nil { + sm.logger.Error("ACP prompt failed", + zap.String("execution_id", execution.ID), + zap.Error(err)) + return nil, fmt.Errorf("prompt failed: %w", err) + } + + // Extract accumulated content from buffers + execution.messageMu.Lock() + agentMessage := execution.messageBuffer.String() + execution.messageBuffer.Reset() + execution.reasoningBuffer.Reset() + execution.summaryBuffer.Reset() + execution.messageMu.Unlock() + + stopReason := "" + if resp != nil { + stopReason = string(resp.StopReason) + } + + sm.logger.Info("ACP prompt completed", + zap.String("execution_id", execution.ID), + zap.String("stop_reason", stopReason)) + + // Publish prompt_complete event + if sm.eventPublisher != nil { + sm.eventPublisher.PublishPromptComplete(execution, agentMessage, "", "") + } + + // Mark as READY for next prompt + if err := markReady(execution.ID); err != nil { + sm.logger.Error("failed to mark execution as ready after prompt", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } + + return &PromptResult{ + StopReason: stopReason, + AgentMessage: agentMessage, + }, nil +} diff --git a/apps/backend/internal/agent/lifecycle/streams.go b/apps/backend/internal/agent/lifecycle/streams.go index 2a37b4b4b2..eb5291f555 100644 --- a/apps/backend/internal/agent/lifecycle/streams.go +++ b/apps/backend/internal/agent/lifecycle/streams.go @@ -6,19 +6,19 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/agentctl" + agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/common/logger" ) // StreamCallbacks defines callbacks for stream events type StreamCallbacks struct { - OnSessionUpdate func(instance *AgentInstance, update agentctl.SessionUpdate) - OnPermission func(instance *AgentInstance, notification *agentctl.PermissionNotification) - OnGitStatus func(instance *AgentInstance, update *agentctl.GitStatusUpdate) - OnFileChange func(instance *AgentInstance, notification *agentctl.FileChangeNotification) + OnSessionUpdate func(execution *AgentExecution, update agentctl.SessionUpdate) + OnPermission func(execution *AgentExecution, notification *agentctl.PermissionNotification) + OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate) + OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification) } -// StreamManager manages WebSocket streams to agent instances +// StreamManager manages WebSocket streams to agent executions type StreamManager struct { logger *logger.Logger callbacks StreamCallbacks @@ -32,21 +32,21 @@ func NewStreamManager(log *logger.Logger, callbacks StreamCallbacks) *StreamMana } } -// ConnectAll connects to all streams for an instance. +// ConnectAll connects to all streams for an execution. // If ready is non-nil, it will be closed when the updates stream is connected. -func (sm *StreamManager) ConnectAll(instance *AgentInstance, ready chan<- struct{}) { - go sm.connectUpdatesStream(instance, ready) - go sm.connectPermissionStream(instance) - go sm.connectGitStatusStream(instance) - go sm.connectFileChangesStream(instance) +func (sm *StreamManager) ConnectAll(execution *AgentExecution, ready chan<- struct{}) { + go sm.connectUpdatesStream(execution, ready) + go sm.connectPermissionStream(execution) + go sm.connectGitStatusStream(execution) + go sm.connectFileChangesStream(execution) } // ReconnectAll reconnects to all streams (used after backend restart). // This waits for agentctl to be ready before connecting to streams. -func (sm *StreamManager) ReconnectAll(instance *AgentInstance) { +func (sm *StreamManager) ReconnectAll(execution *AgentExecution) { sm.logger.Info("reconnecting to agent streams after recovery", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID)) + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID)) // Wait a moment for any startup operations to settle time.Sleep(500 * time.Millisecond) @@ -55,28 +55,28 @@ func (sm *StreamManager) ReconnectAll(instance *AgentInstance) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := instance.agentctl.WaitForReady(ctx, 10*time.Second); err != nil { + if err := execution.agentctl.WaitForReady(ctx, 10*time.Second); err != nil { sm.logger.Warn("agentctl not ready for stream reconnection", - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Error(err)) // Don't return - still try to connect to streams } // Reconnect to WebSocket streams - sm.ConnectAll(instance, nil) + sm.ConnectAll(execution, nil) sm.logger.Info("agent streams reconnected", - zap.String("instance_id", instance.ID), - zap.String("task_id", instance.TaskID)) + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID)) } // connectUpdatesStream handles the updates WebSocket stream with ready signaling -func (sm *StreamManager) connectUpdatesStream(instance *AgentInstance, ready chan<- struct{}) { +func (sm *StreamManager) connectUpdatesStream(execution *AgentExecution, ready chan<- struct{}) { ctx := context.Background() - err := instance.agentctl.StreamUpdates(ctx, func(update agentctl.SessionUpdate) { + err := execution.agentctl.StreamUpdates(ctx, func(update agentctl.SessionUpdate) { if sm.callbacks.OnSessionUpdate != nil { - sm.callbacks.OnSessionUpdate(instance, update) + sm.callbacks.OnSessionUpdate(execution, update) } }) @@ -89,29 +89,29 @@ func (sm *StreamManager) connectUpdatesStream(instance *AgentInstance, ready cha if err != nil { sm.logger.Error("failed to connect to updates stream", - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Error(err)) } } // connectPermissionStream handles the permission WebSocket stream -func (sm *StreamManager) connectPermissionStream(instance *AgentInstance) { +func (sm *StreamManager) connectPermissionStream(execution *AgentExecution) { ctx := context.Background() - err := instance.agentctl.StreamPermissions(ctx, func(notification *agentctl.PermissionNotification) { + err := execution.agentctl.StreamPermissions(ctx, func(notification *agentctl.PermissionNotification) { if sm.callbacks.OnPermission != nil { - sm.callbacks.OnPermission(instance, notification) + sm.callbacks.OnPermission(execution, notification) } }) if err != nil { sm.logger.Error("failed to connect to permission stream", - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Error(err)) } } // connectGitStatusStream handles git status stream with retry logic -func (sm *StreamManager) connectGitStatusStream(instance *AgentInstance) { +func (sm *StreamManager) connectGitStatusStream(execution *AgentExecution) { ctx := context.Background() // Retry connection with exponential backoff @@ -119,9 +119,9 @@ func (sm *StreamManager) connectGitStatusStream(instance *AgentInstance) { backoff := 1 * time.Second for attempt := 1; attempt <= maxRetries; attempt++ { - err := instance.agentctl.StreamGitStatus(ctx, func(update *agentctl.GitStatusUpdate) { + err := execution.agentctl.StreamGitStatus(ctx, func(update *agentctl.GitStatusUpdate) { if sm.callbacks.OnGitStatus != nil { - sm.callbacks.OnGitStatus(instance, update) + sm.callbacks.OnGitStatus(execution, update) } }) @@ -131,7 +131,7 @@ func (sm *StreamManager) connectGitStatusStream(instance *AgentInstance) { } sm.logger.Debug("git status stream connection failed, retrying", - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Int("attempt", attempt), zap.Int("max_retries", maxRetries), zap.Error(err)) @@ -143,12 +143,12 @@ func (sm *StreamManager) connectGitStatusStream(instance *AgentInstance) { } sm.logger.Error("failed to connect to git status stream after retries", - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Int("max_retries", maxRetries)) } // connectFileChangesStream handles file changes stream with retry logic -func (sm *StreamManager) connectFileChangesStream(instance *AgentInstance) { +func (sm *StreamManager) connectFileChangesStream(execution *AgentExecution) { ctx := context.Background() // Retry connection with exponential backoff @@ -156,9 +156,9 @@ func (sm *StreamManager) connectFileChangesStream(instance *AgentInstance) { backoff := 1 * time.Second for attempt := 1; attempt <= maxRetries; attempt++ { - err := instance.agentctl.StreamFileChanges(ctx, func(notification *agentctl.FileChangeNotification) { + err := execution.agentctl.StreamFileChanges(ctx, func(notification *agentctl.FileChangeNotification) { if sm.callbacks.OnFileChange != nil { - sm.callbacks.OnFileChange(instance, notification) + sm.callbacks.OnFileChange(execution, notification) } }) @@ -168,7 +168,7 @@ func (sm *StreamManager) connectFileChangesStream(instance *AgentInstance) { } sm.logger.Debug("file changes stream connection failed, retrying", - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Int("attempt", attempt), zap.Int("max_retries", maxRetries), zap.Error(err)) @@ -180,6 +180,6 @@ func (sm *StreamManager) connectFileChangesStream(instance *AgentInstance) { } sm.logger.Error("failed to connect to file changes stream after retries", - zap.String("instance_id", instance.ID), + zap.String("instance_id", execution.ID), zap.Int("max_retries", maxRetries)) } diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go new file mode 100644 index 0000000000..f3fb11aa97 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -0,0 +1,132 @@ +// Package lifecycle manages agent execution lifecycles including tracking, +// state transitions, and cleanup. +package lifecycle + +import ( + "context" + "strings" + "sync" + "time" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// Default agentctl port +const AgentCtlPort = 9999 + +// AgentExecution represents a running agent execution +type AgentExecution 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) + ACPSessionID string // ACP session ID to resume, if available + AgentCommand string // Command to start the agent subprocess + Status v1.AgentStatus + StartedAt time.Time + FinishedAt *time.Time + ExitCode *int + ErrorMessage string + Progress int + Metadata map[string]interface{} + + // agentctl client for this execution + agentctl *agentctl.Client + + // Standalone mode info (when not using Docker) + standaloneInstanceID string // Instance ID in standalone agentctl + standalonePort int // Port of the standalone execution + + // Buffers for accumulating agent response during a prompt + messageBuffer strings.Builder + reasoningBuffer strings.Builder + summaryBuffer strings.Builder + messageMu sync.Mutex +} + +// GetAgentCtlClient returns the agentctl client for this execution +func (ae *AgentExecution) GetAgentCtlClient() *agentctl.Client { + return ae.agentctl +} + +// LaunchRequest contains parameters for launching an agent +type LaunchRequest struct { + TaskID string + SessionID 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 + ACPSessionID string // ACP session ID to resume, if available + 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) +} + +// ShellStreamStarter is called to start shell streaming for an agent execution +type ShellStreamStarter interface { + StartShellStream(ctx context.Context, taskID string) error +} + +// WorkspaceInfo contains information about a task's workspace for on-demand execution creation +type WorkspaceInfo struct { + TaskID string + SessionID string // Task session ID (from task_sessions table) + WorkspacePath string // Path to the workspace/repository + AgentProfileID string // Optional - agent profile for the task + AgentID string // Agent type ID (e.g., "auggie-agent") - required for runtime creation + ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) +} + +// WorkspaceInfoProvider provides workspace information for tasks +type WorkspaceInfoProvider interface { + // GetWorkspaceInfo returns workspace info for a task (uses most recent session) + GetWorkspaceInfo(ctx context.Context, taskID string) (*WorkspaceInfo, error) + // GetWorkspaceInfoForSession returns workspace info for a specific task session + GetWorkspaceInfoForSession(ctx context.Context, taskID, sessionID string) (*WorkspaceInfo, error) +} + +// RecoveredExecution contains info about an execution recovered from a runtime. +type RecoveredExecution struct { + ExecutionID string + TaskID string + ContainerID string + AgentProfileID string +} + +// 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 +} + diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index bdf28a5007..7be1096e99 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -178,6 +178,32 @@ func (r *Registry) Get(id string) (*AgentTypeConfig, error) { return config, nil } +// GetAgentType is an alias for Get for backward compatibility +func (r *Registry) GetAgentType(id string) (*AgentTypeConfig, error) { + return r.Get(id) +} + +// GetDefault returns the default agent type configuration. +// It tries "augment-agent" first, then falls back to the first enabled agent. +func (r *Registry) GetDefault() (*AgentTypeConfig, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + // Try augment-agent first + if config, exists := r.agents["augment-agent"]; exists && config.Enabled { + return config, nil + } + + // Fall back to first enabled agent + for _, config := range r.agents { + if config.Enabled { + return config, nil + } + } + + return nil, fmt.Errorf("no default agent type available") +} + // List returns all registered agent types func (r *Registry) List() []*AgentTypeConfig { r.mu.RLock() diff --git a/apps/backend/internal/agent/agentctl/acp.go b/apps/backend/internal/agentctl/client/acp.go similarity index 82% rename from apps/backend/internal/agent/agentctl/acp.go rename to apps/backend/internal/agentctl/client/acp.go index 6b8e811a47..db9281ef73 100644 --- a/apps/backend/internal/agent/agentctl/acp.go +++ b/apps/backend/internal/agentctl/client/acp.go @@ -1,4 +1,4 @@ -package agentctl +package client import ( "bytes" @@ -57,9 +57,18 @@ func (c *Client) Initialize(ctx context.Context, clientName, clientVersion strin } }() + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("initialize request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var result InitializeResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse initialize response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { return nil, fmt.Errorf("initialize failed: %s", result.Error) @@ -101,9 +110,18 @@ func (c *Client) NewSession(ctx context.Context, cwd string) (string, error) { } }() + respBody, err := readResponseBody(resp) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("new session request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var result NewSessionResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("failed to parse new session response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { return "", fmt.Errorf("new session failed: %s", result.Error) @@ -138,12 +156,21 @@ func (c *Client) LoadSession(ctx context.Context, sessionID string) error { } }() + respBody, err := readResponseBody(resp) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("load session request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var result struct { Success bool `json:"success"` Error string `json:"error,omitempty"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return err + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("failed to parse load session response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { return fmt.Errorf("load session failed: %s", result.Error) @@ -191,9 +218,18 @@ func (c *Client) Prompt(ctx context.Context, text string) (*PromptResponse, erro } }() + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("prompt request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var result PromptResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse prompt response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { return nil, fmt.Errorf("prompt failed: %s", result.Error) @@ -421,9 +457,18 @@ func (c *Client) RespondToPermission(ctx context.Context, pendingID, optionID st } }() + respBody, err := readResponseBody(resp) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("permission response request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var result PermissionRespondResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return err + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("failed to parse permission response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { return fmt.Errorf("permission response failed: %s", result.Error) diff --git a/apps/backend/internal/agent/agentctl/client.go b/apps/backend/internal/agentctl/client/client.go similarity index 75% rename from apps/backend/internal/agent/agentctl/client.go rename to apps/backend/internal/agentctl/client/client.go index 4ec5d4a916..fc23fbd43f 100644 --- a/apps/backend/internal/agent/agentctl/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -1,7 +1,8 @@ // Package agentctl provides a client for communicating with agentctl running inside containers -package agentctl +package client import ( + "bytes" "context" "encoding/json" "fmt" @@ -10,7 +11,7 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/kandev/kandev/internal/agentctl/process" + "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -36,9 +37,10 @@ type StatusResponse struct { ProcessInfo map[string]interface{} `json:"process_info"` } -// IsAgentRunning returns true if the agent process is running +// IsAgentRunning returns true if the agent process is running or starting +// (i.e., the agent is active and should not be considered stale) func (s *StatusResponse) IsAgentRunning() bool { - return s.AgentStatus == "running" + return s.AgentStatus == "running" || s.AgentStatus == "starting" } // NewClient creates a new agentctl client @@ -84,13 +86,73 @@ func (c *Client) GetStatus(ctx context.Context) (*StatusResponse, error) { } defer func() { _ = resp.Body.Close() }() + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("status request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var status StatusResponse - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return nil, err + if err := json.Unmarshal(respBody, &status); err != nil { + return nil, fmt.Errorf("failed to parse status response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } return &status, nil } +// ConfigureAgent configures the agent command. Must be called before Start(). +func (c *Client) ConfigureAgent(ctx context.Context, command string, env map[string]string) error { + payload := struct { + Command string `json:"command"` + Env map[string]string `json:"env,omitempty"` + }{ + Command: command, + Env: env, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/configure", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + // Read the response body for better error handling + respBody, err := readResponseBody(resp) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Check HTTP status code first + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("configure request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("failed to parse configure response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) + } + if !result.Success { + return fmt.Errorf("configure failed: %s", result.Error) + } + return nil +} + // Start starts the agent process func (c *Client) Start(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/start", nil) @@ -104,12 +166,21 @@ func (c *Client) Start(ctx context.Context) error { } defer func() { _ = resp.Body.Close() }() + respBody, err := readResponseBody(resp) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("start request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var result struct { Success bool `json:"success"` Error string `json:"error,omitempty"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return err + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("failed to parse start response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { return fmt.Errorf("start failed: %s", result.Error) @@ -130,12 +201,21 @@ func (c *Client) Stop(ctx context.Context) error { } defer func() { _ = resp.Body.Close() }() + respBody, err := readResponseBody(resp) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("stop request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var result struct { Success bool `json:"success"` Error string `json:"error,omitempty"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return err + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("failed to parse stop response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { return fmt.Errorf("stop failed: %s", result.Error) @@ -171,18 +251,18 @@ func (c *Client) BaseURL() string { return c.baseURL } -// Re-export types from process package for convenience +// Re-export types from types package for convenience type ( - GitStatusUpdate = process.GitStatusUpdate - FileInfo = process.FileInfo - FileListUpdate = process.FileListUpdate - FileEntry = process.FileEntry - FileTreeNode = process.FileTreeNode - FileTreeRequest = process.FileTreeRequest - FileTreeResponse = process.FileTreeResponse - FileContentRequest = process.FileContentRequest - FileContentResponse = process.FileContentResponse - FileChangeNotification = process.FileChangeNotification + GitStatusUpdate = types.GitStatusUpdate + FileInfo = types.FileInfo + FileListUpdate = types.FileListUpdate + FileEntry = types.FileEntry + FileTreeNode = types.FileTreeNode + FileTreeRequest = types.FileTreeRequest + FileTreeResponse = types.FileTreeResponse + FileContentRequest = types.FileContentRequest + FileContentResponse = types.FileContentResponse + FileChangeNotification = types.FileChangeNotification ) // StreamGitStatus opens a WebSocket connection for streaming git status updates @@ -516,3 +596,21 @@ func (c *Client) CloseShellStream() { c.shellConn = nil } } + +// readResponseBody reads and returns the response body +func readResponseBody(resp *http.Response) ([]byte, error) { + var buf bytes.Buffer + if _, err := buf.ReadFrom(resp.Body); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// truncateBody truncates body for error messages to avoid huge logs +func truncateBody(body []byte) string { + const maxLen = 200 + if len(body) > maxLen { + return string(body[:maxLen]) + "..." + } + return string(body) +} diff --git a/apps/backend/internal/agent/agentctl/launcher/launcher.go b/apps/backend/internal/agentctl/client/launcher/launcher.go similarity index 100% rename from apps/backend/internal/agent/agentctl/launcher/launcher.go rename to apps/backend/internal/agentctl/client/launcher/launcher.go diff --git a/apps/backend/internal/agent/agentctl/launcher/sysprocattr_default.go b/apps/backend/internal/agentctl/client/launcher/sysprocattr_default.go similarity index 100% rename from apps/backend/internal/agent/agentctl/launcher/sysprocattr_default.go rename to apps/backend/internal/agentctl/client/launcher/sysprocattr_default.go diff --git a/apps/backend/internal/agent/agentctl/launcher/sysprocattr_linux.go b/apps/backend/internal/agentctl/client/launcher/sysprocattr_linux.go similarity index 100% rename from apps/backend/internal/agent/agentctl/launcher/sysprocattr_linux.go rename to apps/backend/internal/agentctl/client/launcher/sysprocattr_linux.go diff --git a/apps/backend/internal/agent/agentctl/standalone.go b/apps/backend/internal/agentctl/client/standalone.go similarity index 99% rename from apps/backend/internal/agent/agentctl/standalone.go rename to apps/backend/internal/agentctl/client/standalone.go index 68dca7f09b..942bcba510 100644 --- a/apps/backend/internal/agent/agentctl/standalone.go +++ b/apps/backend/internal/agentctl/client/standalone.go @@ -1,6 +1,6 @@ // Package agentctl provides a client for communicating with agentctl. // This file contains the StandaloneCtl client for the multi-instance control API. -package agentctl +package client import ( "bytes" diff --git a/apps/backend/internal/agentctl/process/types.go b/apps/backend/internal/agentctl/process/types.go deleted file mode 100644 index bd2ffe54bb..0000000000 --- a/apps/backend/internal/agentctl/process/types.go +++ /dev/null @@ -1,94 +0,0 @@ -package process - -import "time" - -// GitStatusUpdate represents a git status update -type GitStatusUpdate struct { - Timestamp time.Time `json:"timestamp"` - Modified []string `json:"modified"` - Added []string `json:"added"` - Deleted []string `json:"deleted"` - Untracked []string `json:"untracked"` - Renamed []string `json:"renamed"` - Ahead int `json:"ahead"` - Behind int `json:"behind"` - Branch string `json:"branch"` - RemoteBranch string `json:"remote_branch,omitempty"` - Files map[string]FileInfo `json:"files,omitempty"` -} - -// FileInfo represents information about a file -type FileInfo struct { - Path string `json:"path"` - Status string `json:"status"` // modified, added, deleted, untracked, renamed - Additions int `json:"additions,omitempty"` - Deletions int `json:"deletions,omitempty"` - OldPath string `json:"old_path,omitempty"` // For renamed files - Diff string `json:"diff,omitempty"` -} - -// FileListUpdate represents a file listing update -type FileListUpdate struct { - Timestamp time.Time `json:"timestamp"` - Files []FileEntry `json:"files"` -} - -// FileEntry represents a file in the workspace -type FileEntry struct { - Path string `json:"path"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size,omitempty"` -} - -// FileTreeNode represents a node in the file tree -type FileTreeNode struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size,omitempty"` - Children []*FileTreeNode `json:"children,omitempty"` -} - -// FileTreeRequest represents a request for file tree -type FileTreeRequest struct { - Path string `json:"path"` // Path to get tree for (relative to workspace root) - Depth int `json:"depth"` // Depth to traverse (0 = unlimited, 1 = immediate children only) -} - -// FileTreeResponse represents a response with file tree -type FileTreeResponse struct { - RequestID string `json:"request_id"` - Root *FileTreeNode `json:"root"` - Error string `json:"error,omitempty"` -} - -// FileContentRequest represents a request for file content -type FileContentRequest struct { - Path string `json:"path"` // Path to file (relative to workspace root) -} - -// FileContentResponse represents a response with file content -type FileContentResponse struct { - RequestID string `json:"request_id"` - Path string `json:"path"` - Content string `json:"content"` - Size int64 `json:"size"` - Error string `json:"error,omitempty"` -} - -// FileChangeNotification represents a filesystem change notification -type FileChangeNotification struct { - Timestamp time.Time `json:"timestamp"` - Path string `json:"path"` - Operation string `json:"operation"` // create, write, remove, rename, chmod, refresh -} - -// GitStatusSubscriber is a channel that receives git status updates -type GitStatusSubscriber chan GitStatusUpdate - -// FilesSubscriber is a channel that receives file listing updates -type FilesSubscriber chan FileListUpdate - -// FileChangeSubscriber is a channel that receives file change notifications -type FileChangeSubscriber chan FileChangeNotification - diff --git a/apps/backend/internal/agentctl/acp/client.go b/apps/backend/internal/agentctl/server/acp/client.go similarity index 100% rename from apps/backend/internal/agentctl/acp/client.go rename to apps/backend/internal/agentctl/server/acp/client.go diff --git a/apps/backend/internal/agentctl/adapter/acp_adapter.go b/apps/backend/internal/agentctl/server/adapter/acp_adapter.go similarity index 99% rename from apps/backend/internal/agentctl/adapter/acp_adapter.go rename to apps/backend/internal/agentctl/server/adapter/acp_adapter.go index dfc8fb5c63..fd59c6a46e 100644 --- a/apps/backend/internal/agentctl/adapter/acp_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/acp_adapter.go @@ -8,7 +8,7 @@ import ( "sync" "github.com/coder/acp-go-sdk" - acpclient "github.com/kandev/kandev/internal/agentctl/acp" + acpclient "github.com/kandev/kandev/internal/agentctl/server/acp" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) diff --git a/apps/backend/internal/agentctl/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go similarity index 100% rename from apps/backend/internal/agentctl/adapter/adapter.go rename to apps/backend/internal/agentctl/server/adapter/adapter.go diff --git a/apps/backend/internal/agentctl/adapter/adapter_test.go b/apps/backend/internal/agentctl/server/adapter/adapter_test.go similarity index 100% rename from apps/backend/internal/agentctl/adapter/adapter_test.go rename to apps/backend/internal/agentctl/server/adapter/adapter_test.go diff --git a/apps/backend/internal/agentctl/adapter/codex_adapter.go b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go similarity index 100% rename from apps/backend/internal/agentctl/adapter/codex_adapter.go rename to apps/backend/internal/agentctl/server/adapter/codex_adapter.go diff --git a/apps/backend/internal/agentctl/api/acp.go b/apps/backend/internal/agentctl/server/api/acp.go similarity index 100% rename from apps/backend/internal/agentctl/api/acp.go rename to apps/backend/internal/agentctl/server/api/acp.go diff --git a/apps/backend/internal/agentctl/api/control_server.go b/apps/backend/internal/agentctl/server/api/control_server.go similarity index 96% rename from apps/backend/internal/agentctl/api/control_server.go rename to apps/backend/internal/agentctl/server/api/control_server.go index 5599d6d375..89445c3f98 100644 --- a/apps/backend/internal/agentctl/api/control_server.go +++ b/apps/backend/internal/agentctl/server/api/control_server.go @@ -6,8 +6,8 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/agentctl/config" - "github.com/kandev/kandev/internal/agentctl/instance" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) diff --git a/apps/backend/internal/agentctl/api/server.go b/apps/backend/internal/agentctl/server/api/server.go similarity index 85% rename from apps/backend/internal/agentctl/api/server.go rename to apps/backend/internal/agentctl/server/api/server.go index ea3b3cf44a..0293443cbf 100644 --- a/apps/backend/internal/agentctl/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" - "github.com/kandev/kandev/internal/agentctl/config" - "github.com/kandev/kandev/internal/agentctl/process" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -60,6 +60,7 @@ func (s *Server) setupRoutes() { api.GET("/info", s.handleInfo) // Process control + api.POST("/agent/configure", s.handleAgentConfigure) api.POST("/start", s.handleStart) api.POST("/stop", s.handleStop) @@ -162,6 +163,52 @@ func (s *Server) handleStart(c *gin.Context) { }) } +// AgentConfigure request/response - configures the agent command before starting +type AgentConfigureRequest struct { + Command string `json:"command"` + Env map[string]string `json:"env,omitempty"` +} + +type AgentConfigureResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleAgentConfigure(c *gin.Context) { + var req AgentConfigureRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, AgentConfigureResponse{ + Success: false, + Error: "invalid request: " + err.Error(), + }) + return + } + + if req.Command == "" { + c.JSON(http.StatusBadRequest, AgentConfigureResponse{ + Success: false, + Error: "command is required", + }) + return + } + + if err := s.procMgr.Configure(req.Command, req.Env); err != nil { + s.logger.Error("failed to configure agent", zap.Error(err), zap.String("command", req.Command)) + c.JSON(http.StatusInternalServerError, AgentConfigureResponse{ + Success: false, + Error: err.Error(), + }) + return + } + + s.logger.Info("agent configured", zap.String("command", req.Command)) + c.JSON(http.StatusOK, AgentConfigureResponse{ + Success: true, + Message: "agent configured", + }) +} + // Stop request/response type StopRequest struct { Force bool `json:"force"` diff --git a/apps/backend/internal/agentctl/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go similarity index 91% rename from apps/backend/internal/agentctl/api/workspace.go rename to apps/backend/internal/agentctl/server/api/workspace.go index 5317289ad7..e9e6c34afb 100644 --- a/apps/backend/internal/agentctl/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" - "github.com/kandev/kandev/internal/agentctl/process" + "github.com/kandev/kandev/internal/agentctl/types" "go.uber.org/zap" ) @@ -171,28 +171,28 @@ func (s *Server) handleFileTree(c *gin.Context) { tree, err := s.procMgr.GetWorkspaceTracker().GetFileTree(path, depth) if err != nil { - c.JSON(400, process.FileTreeResponse{Error: err.Error()}) + c.JSON(400, types.FileTreeResponse{Error: err.Error()}) return } - c.JSON(200, process.FileTreeResponse{Root: tree}) + c.JSON(200, types.FileTreeResponse{Root: tree}) } // handleFileContent handles file content requests via HTTP GET func (s *Server) handleFileContent(c *gin.Context) { path := c.Query("path") if path == "" { - c.JSON(400, process.FileContentResponse{Error: "path is required"}) + c.JSON(400, types.FileContentResponse{Error: "path is required"}) return } content, size, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path) if err != nil { - c.JSON(400, process.FileContentResponse{Path: path, Error: err.Error(), Size: size}) + c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) return } - c.JSON(200, process.FileContentResponse{Path: path, Content: content, Size: size}) + c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size}) } // mustParseInt parses a string to int, returns 0 on error diff --git a/apps/backend/internal/agentctl/config/config.go b/apps/backend/internal/agentctl/server/config/config.go similarity index 100% rename from apps/backend/internal/agentctl/config/config.go rename to apps/backend/internal/agentctl/server/config/config.go diff --git a/apps/backend/internal/agentctl/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go similarity index 98% rename from apps/backend/internal/agentctl/instance/instance.go rename to apps/backend/internal/agentctl/server/instance/instance.go index 566adb40b2..bd7dbdaa26 100644 --- a/apps/backend/internal/agentctl/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -7,7 +7,7 @@ import ( "net/http" "time" - "github.com/kandev/kandev/internal/agentctl/process" + "github.com/kandev/kandev/internal/agentctl/server/process" ) // Instance represents a single agent instance running as a subprocess. diff --git a/apps/backend/internal/agentctl/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go similarity index 98% rename from apps/backend/internal/agentctl/instance/manager.go rename to apps/backend/internal/agentctl/server/instance/manager.go index 415d6896b8..93be0cde5c 100644 --- a/apps/backend/internal/agentctl/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -10,8 +10,8 @@ import ( "time" "github.com/google/uuid" - "github.com/kandev/kandev/internal/agentctl/config" - "github.com/kandev/kandev/internal/agentctl/process" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/agent" "go.uber.org/zap" diff --git a/apps/backend/internal/agentctl/instance/port_allocator.go b/apps/backend/internal/agentctl/server/instance/port_allocator.go similarity index 100% rename from apps/backend/internal/agentctl/instance/port_allocator.go rename to apps/backend/internal/agentctl/server/instance/port_allocator.go diff --git a/apps/backend/internal/agentctl/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go similarity index 93% rename from apps/backend/internal/agentctl/process/manager.go rename to apps/backend/internal/agentctl/server/process/manager.go index 2fae6a6ccb..6950ef8e78 100644 --- a/apps/backend/internal/agentctl/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -11,9 +11,9 @@ import ( "sync/atomic" "time" - "github.com/kandev/kandev/internal/agentctl/adapter" - "github.com/kandev/kandev/internal/agentctl/config" - "github.com/kandev/kandev/internal/agentctl/shell" + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/shell" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/agent" "go.uber.org/zap" @@ -235,6 +235,44 @@ func (m *Manager) Start(ctx context.Context) error { return nil } +// Configure sets the agent command and optional environment variables. +// This must be called before Start() if the instance was created without a command. +func (m *Manager) Configure(command string, env map[string]string) error { + m.startMu.Lock() + defer m.startMu.Unlock() + + if m.Status() == StatusRunning || m.Status() == StatusStarting { + return fmt.Errorf("cannot configure while agent is running") + } + + if command == "" { + return fmt.Errorf("agent command cannot be empty") + } + + // Parse the command string and update config + args := config.ParseCommand(command) + if len(args) == 0 { + return fmt.Errorf("failed to parse agent command") + } + + m.cfg.AgentCommand = command + m.cfg.AgentArgs = args + + // Merge additional env vars + if len(env) > 0 { + for k, v := range env { + m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v)) + } + } + + m.logger.Info("agent configured", + zap.String("command", command), + zap.Strings("args", args), + zap.Int("env_count", len(env))) + + return nil +} + // createAdapter creates the appropriate protocol adapter based on configuration func (m *Manager) createAdapter() error { protocol := m.cfg.Protocol diff --git a/apps/backend/internal/agentctl/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go similarity index 89% rename from apps/backend/internal/agentctl/process/workspace_tracker.go rename to apps/backend/internal/agentctl/server/process/workspace_tracker.go index a18fbc92dd..a559dc4dc5 100644 --- a/apps/backend/internal/agentctl/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -14,6 +14,7 @@ import ( "time" "github.com/fsnotify/fsnotify" + "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -24,14 +25,14 @@ type WorkspaceTracker struct { logger *logger.Logger // Current state - currentStatus GitStatusUpdate - currentFiles FileListUpdate + currentStatus types.GitStatusUpdate + currentFiles types.FileListUpdate mu sync.RWMutex // Subscribers - gitStatusSubscribers map[GitStatusSubscriber]struct{} - filesSubscribers map[FilesSubscriber]struct{} - fileChangeSubscribers map[FileChangeSubscriber]struct{} + gitStatusSubscribers map[types.GitStatusSubscriber]struct{} + filesSubscribers map[types.FilesSubscriber]struct{} + fileChangeSubscribers map[types.FileChangeSubscriber]struct{} subMu sync.RWMutex // Filesystem watcher @@ -56,9 +57,9 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { return &WorkspaceTracker{ workDir: workDir, logger: log.WithFields(zap.String("component", "workspace-tracker")), - gitStatusSubscribers: make(map[GitStatusSubscriber]struct{}), - filesSubscribers: make(map[FilesSubscriber]struct{}), - fileChangeSubscribers: make(map[FileChangeSubscriber]struct{}), + gitStatusSubscribers: make(map[types.GitStatusSubscriber]struct{}), + filesSubscribers: make(map[types.FilesSubscriber]struct{}), + fileChangeSubscribers: make(map[types.FileChangeSubscriber]struct{}), watcher: watcher, fsChangeTrigger: make(chan struct{}, 1), // Buffered to avoid blocking stopCh: make(chan struct{}), @@ -147,7 +148,7 @@ func (wt *WorkspaceTracker) monitorLoop(ctx context.Context) { wt.updateGitStatus(ctx) wt.updateFiles(ctx) // Notify file change subscribers that workspace changed - wt.notifyFileChangeSubscribers(FileChangeNotification{ + wt.notifyFileChangeSubscribers(types.FileChangeNotification{ Timestamp: time.Now(), Path: "", Operation: "refresh", @@ -175,15 +176,15 @@ func (wt *WorkspaceTracker) updateGitStatus(ctx context.Context) { } // getGitStatus retrieves the current git status -func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (GitStatusUpdate, error) { - update := GitStatusUpdate{ +func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUpdate, error) { + update := types.GitStatusUpdate{ Timestamp: time.Now(), Modified: []string{}, Added: []string{}, Deleted: []string{}, Untracked: []string{}, Renamed: []string{}, - Files: make(map[string]FileInfo), + Files: make(map[string]types.FileInfo), } // Get current branch @@ -233,7 +234,7 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (GitStatusUpdate, statusCode := line[:2] filePath := strings.TrimSpace(line[3:]) - fileInfo := FileInfo{ + fileInfo := types.FileInfo{ Path: filePath, } @@ -271,7 +272,7 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (GitStatusUpdate, } // enrichWithDiffData adds diff information (additions, deletions, diff content) to file info -func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *GitStatusUpdate) { +func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *types.GitStatusUpdate) { // Determine the base ref to compare against // Use remote branch if available, otherwise use HEAD baseRef := "HEAD" @@ -321,7 +322,7 @@ func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *GitS } else { // File might be committed but not in status (because it's committed) // Add it to the Files map - fileInfo := FileInfo{ + fileInfo := types.FileInfo{ Path: filePath, Status: "modified", // Assume modified if it has a diff Additions: additions, @@ -431,10 +432,10 @@ func (wt *WorkspaceTracker) updateFiles(ctx context.Context) { } // getFileList retrieves the list of files in the workspace -func (wt *WorkspaceTracker) getFileList(ctx context.Context) (FileListUpdate, error) { - update := FileListUpdate{ +func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpdate, error) { + update := types.FileListUpdate{ Timestamp: time.Now(), - Files: []FileEntry{}, + Files: []types.FileEntry{}, } // Use git ls-files to get tracked files @@ -451,7 +452,7 @@ func (wt *WorkspaceTracker) getFileList(ctx context.Context) (FileListUpdate, er if line == "" { continue } - update.Files = append(update.Files, FileEntry{ + update.Files = append(update.Files, types.FileEntry{ Path: line, IsDir: false, }) @@ -465,8 +466,8 @@ func (wt *WorkspaceTracker) getFileList(ctx context.Context) (FileListUpdate, er // Subscriber management methods // SubscribeGitStatus creates a new git status subscriber -func (wt *WorkspaceTracker) SubscribeGitStatus() GitStatusSubscriber { - sub := make(GitStatusSubscriber, 10) +func (wt *WorkspaceTracker) SubscribeGitStatus() types.GitStatusSubscriber { + sub := make(types.GitStatusSubscriber, 10) wt.subMu.Lock() wt.gitStatusSubscribers[sub] = struct{}{} @@ -490,7 +491,7 @@ func (wt *WorkspaceTracker) SubscribeGitStatus() GitStatusSubscriber { } // UnsubscribeGitStatus removes a git status subscriber -func (wt *WorkspaceTracker) UnsubscribeGitStatus(sub GitStatusSubscriber) { +func (wt *WorkspaceTracker) UnsubscribeGitStatus(sub types.GitStatusSubscriber) { wt.subMu.Lock() delete(wt.gitStatusSubscribers, sub) wt.subMu.Unlock() @@ -498,8 +499,8 @@ func (wt *WorkspaceTracker) UnsubscribeGitStatus(sub GitStatusSubscriber) { } // SubscribeFiles creates a new files subscriber -func (wt *WorkspaceTracker) SubscribeFiles() FilesSubscriber { - sub := make(FilesSubscriber, 10) +func (wt *WorkspaceTracker) SubscribeFiles() types.FilesSubscriber { + sub := make(types.FilesSubscriber, 10) wt.subMu.Lock() wt.filesSubscribers[sub] = struct{}{} @@ -523,7 +524,7 @@ func (wt *WorkspaceTracker) SubscribeFiles() FilesSubscriber { } // UnsubscribeFiles removes a files subscriber -func (wt *WorkspaceTracker) UnsubscribeFiles(sub FilesSubscriber) { +func (wt *WorkspaceTracker) UnsubscribeFiles(sub types.FilesSubscriber) { wt.subMu.Lock() delete(wt.filesSubscribers, sub) wt.subMu.Unlock() @@ -533,7 +534,7 @@ func (wt *WorkspaceTracker) UnsubscribeFiles(sub FilesSubscriber) { // Notification methods // notifyGitStatusSubscribers notifies all git status subscribers -func (wt *WorkspaceTracker) notifyGitStatusSubscribers(update GitStatusUpdate) { +func (wt *WorkspaceTracker) notifyGitStatusSubscribers(update types.GitStatusUpdate) { wt.subMu.RLock() defer wt.subMu.RUnlock() @@ -547,7 +548,7 @@ func (wt *WorkspaceTracker) notifyGitStatusSubscribers(update GitStatusUpdate) { } // notifyFilesSubscribers notifies all files subscribers -func (wt *WorkspaceTracker) notifyFilesSubscribers(update FileListUpdate) { +func (wt *WorkspaceTracker) notifyFilesSubscribers(update types.FileListUpdate) { wt.subMu.RLock() defer wt.subMu.RUnlock() @@ -563,8 +564,8 @@ func (wt *WorkspaceTracker) notifyFilesSubscribers(update FileListUpdate) { // SubscribeFileChanges creates a new file change subscriber -func (wt *WorkspaceTracker) SubscribeFileChanges() FileChangeSubscriber { - sub := make(FileChangeSubscriber, 100) +func (wt *WorkspaceTracker) SubscribeFileChanges() types.FileChangeSubscriber { + sub := make(types.FileChangeSubscriber, 100) wt.subMu.Lock() wt.fileChangeSubscribers[sub] = struct{}{} @@ -574,7 +575,7 @@ func (wt *WorkspaceTracker) SubscribeFileChanges() FileChangeSubscriber { } // UnsubscribeFileChanges removes a file change subscriber -func (wt *WorkspaceTracker) UnsubscribeFileChanges(sub FileChangeSubscriber) { +func (wt *WorkspaceTracker) UnsubscribeFileChanges(sub types.FileChangeSubscriber) { wt.subMu.Lock() delete(wt.fileChangeSubscribers, sub) wt.subMu.Unlock() @@ -582,7 +583,7 @@ func (wt *WorkspaceTracker) UnsubscribeFileChanges(sub FileChangeSubscriber) { } // notifyFileChangeSubscribers notifies all file change subscribers -func (wt *WorkspaceTracker) notifyFileChangeSubscribers(notification FileChangeNotification) { +func (wt *WorkspaceTracker) notifyFileChangeSubscribers(notification types.FileChangeNotification) { wt.subMu.RLock() defer wt.subMu.RUnlock() @@ -660,7 +661,7 @@ func (wt *WorkspaceTracker) addDirectoryRecursive(dir string) error { } // GetFileTree returns the file tree for a given path and depth -func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*FileTreeNode, error) { +func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*types.FileTreeNode, error) { // Resolve the full path fullPath := filepath.Join(wt.workDir, reqPath) @@ -680,8 +681,8 @@ func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*FileTreeNod } // buildFileTreeNode recursively builds a file tree node -func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.FileInfo, maxDepth, currentDepth int) (*FileTreeNode, error) { - node := &FileTreeNode{ +func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os.FileInfo, maxDepth, currentDepth int) (*types.FileTreeNode, error) { + node := &types.FileTreeNode{ Name: info.Name(), Path: relPath, IsDir: info.IsDir(), @@ -700,7 +701,7 @@ func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os. } // Build children - node.Children = make([]*FileTreeNode, 0, len(entries)) + node.Children = make([]*types.FileTreeNode, 0, len(entries)) for _, entry := range entries { // Skip hidden files and common ignore patterns name := entry.Name() diff --git a/apps/backend/internal/agentctl/shell/session.go b/apps/backend/internal/agentctl/server/shell/session.go similarity index 100% rename from apps/backend/internal/agentctl/shell/session.go rename to apps/backend/internal/agentctl/server/shell/session.go diff --git a/apps/backend/internal/agentctl/shell/session_test.go b/apps/backend/internal/agentctl/server/shell/session_test.go similarity index 100% rename from apps/backend/internal/agentctl/shell/session_test.go rename to apps/backend/internal/agentctl/server/shell/session_test.go diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go index 52dc51fd04..0ad05bc02e 100644 --- a/apps/backend/internal/agentctl/types/types.go +++ b/apps/backend/internal/agentctl/types/types.go @@ -2,7 +2,10 @@ // This breaks import cycles between adapter and acp packages. package types -import "context" +import ( + "context" + "time" +) // Permission action types - categorize the kind of action requiring approval const ( @@ -56,3 +59,93 @@ type PermissionResponse struct { // PermissionHandler is called when the agent requests permission for an action type PermissionHandler func(ctx context.Context, req *PermissionRequest) (*PermissionResponse, error) +// GitStatusUpdate represents a git status update +type GitStatusUpdate struct { + Timestamp time.Time `json:"timestamp"` + Modified []string `json:"modified"` + Added []string `json:"added"` + Deleted []string `json:"deleted"` + Untracked []string `json:"untracked"` + Renamed []string `json:"renamed"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Branch string `json:"branch"` + RemoteBranch string `json:"remote_branch,omitempty"` + Files map[string]FileInfo `json:"files,omitempty"` +} + +// FileInfo represents information about a file +type FileInfo struct { + Path string `json:"path"` + Status string `json:"status"` // modified, added, deleted, untracked, renamed + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + OldPath string `json:"old_path,omitempty"` // For renamed files + Diff string `json:"diff,omitempty"` +} + +// FileListUpdate represents a file listing update +type FileListUpdate struct { + Timestamp time.Time `json:"timestamp"` + Files []FileEntry `json:"files"` +} + +// FileEntry represents a file in the workspace +type FileEntry struct { + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size,omitempty"` +} + +// FileTreeNode represents a node in the file tree +type FileTreeNode struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size,omitempty"` + Children []*FileTreeNode `json:"children,omitempty"` +} + +// FileTreeRequest represents a request for file tree +type FileTreeRequest struct { + Path string `json:"path"` // Path to get tree for (relative to workspace root) + Depth int `json:"depth"` // Depth to traverse (0 = unlimited, 1 = immediate children only) +} + +// FileTreeResponse represents a response with file tree +type FileTreeResponse struct { + RequestID string `json:"request_id"` + Root *FileTreeNode `json:"root"` + Error string `json:"error,omitempty"` +} + +// FileContentRequest represents a request for file content +type FileContentRequest struct { + Path string `json:"path"` // Path to file (relative to workspace root) +} + +// FileContentResponse represents a response with file content +type FileContentResponse struct { + RequestID string `json:"request_id"` + Path string `json:"path"` + Content string `json:"content"` + Size int64 `json:"size"` + Error string `json:"error,omitempty"` +} + +// FileChangeNotification represents a filesystem change notification +type FileChangeNotification struct { + Timestamp time.Time `json:"timestamp"` + Path string `json:"path"` + Operation string `json:"operation"` // create, write, remove, rename, chmod, refresh +} + +// GitStatusSubscriber is a channel that receives git status updates +type GitStatusSubscriber chan GitStatusUpdate + +// FilesSubscriber is a channel that receives file listing updates +type FilesSubscriber chan FileListUpdate + +// FileChangeSubscriber is a channel that receives file change notifications +type FileChangeSubscriber chan FileChangeNotification + diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index b4069e4fc9..3a89f29f2a 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -55,7 +55,7 @@ type SimulatedAgentManagerClient struct { shouldFail bool failAfter int // Fail after N successful launches launchCount int32 - acpMessageFn func(taskID, instanceID string) []protocol.Message // Custom ACP messages + acpMessageFn func(taskID, executionID string) []protocol.Message // Custom ACP messages stopCh chan struct{} } @@ -102,7 +102,7 @@ func (s *SimulatedAgentManagerClient) SetFailAfter(n int) { } // SetACPMessageFn sets a custom function to generate ACP messages -func (s *SimulatedAgentManagerClient) SetACPMessageFn(fn func(taskID, instanceID string) []protocol.Message) { +func (s *SimulatedAgentManagerClient) SetACPMessageFn(fn func(taskID, executionID string) []protocol.Message) { s.acpMessageFn = fn } @@ -118,30 +118,37 @@ func (s *SimulatedAgentManagerClient) LaunchAgent(ctx context.Context, req *exec return nil, fmt.Errorf("simulated launch failure after %d attempts", s.failAfter) } - instanceID := uuid.New().String() - containerID := "sim-container-" + instanceID[:8] + executionID := uuid.New().String() + containerID := "sim-container-" + executionID[:8] s.mu.Lock() instance := &simulatedInstance{ - id: instanceID, + id: executionID, taskID: req.TaskID, agentProfileID: req.AgentProfileID, status: v1.AgentStatusStarting, stopCh: make(chan struct{}), } - s.instances[instanceID] = instance + s.instances[executionID] = instance s.mu.Unlock() // Simulate agent lifecycle in background go s.runAgentSimulation(instance, req) return &executor.LaunchAgentResponse{ - AgentInstanceID: instanceID, + AgentExecutionID: executionID, ContainerID: containerID, Status: v1.AgentStatusStarting, }, nil } +// StartAgentProcess simulates starting the agent subprocess +func (s *SimulatedAgentManagerClient) StartAgentProcess(ctx context.Context, agentExecutionID string) error { + s.logger.Info("simulated: starting agent process", + zap.String("agent_execution_id", agentExecutionID)) + return nil +} + // runAgentSimulation simulates the agent execution lifecycle func (s *SimulatedAgentManagerClient) runAgentSimulation(instance *simulatedInstance, req *executor.LaunchAgentRequest) { // Wait for launch delay @@ -259,42 +266,42 @@ func (s *SimulatedAgentManagerClient) publishACPMessages(instance *simulatedInst } // StopAgent simulates stopping an agent -func (s *SimulatedAgentManagerClient) StopAgent(ctx context.Context, agentInstanceID string, force bool) error { +func (s *SimulatedAgentManagerClient) StopAgent(ctx context.Context, agentExecutionID string, force bool) error { s.mu.Lock() - instance, exists := s.instances[agentInstanceID] + execution, exists := s.instances[agentExecutionID] s.mu.Unlock() if !exists { - return fmt.Errorf("agent instance %q not found", agentInstanceID) + return fmt.Errorf("agent execution %q not found", agentExecutionID) } - close(instance.stopCh) - instance.statusMu.Lock() - instance.status = v1.AgentStatusStopped - instance.statusMu.Unlock() + close(execution.stopCh) + execution.statusMu.Lock() + execution.status = v1.AgentStatusStopped + execution.statusMu.Unlock() - s.publishAgentEvent(events.AgentStopped, instance) + s.publishAgentEvent(events.AgentStopped, execution) return nil } // GetAgentStatus returns the status of a simulated agent -func (s *SimulatedAgentManagerClient) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) { +func (s *SimulatedAgentManagerClient) GetAgentStatus(ctx context.Context, agentExecutionID string) (*v1.AgentExecution, error) { s.mu.Lock() - instance, exists := s.instances[agentInstanceID] + execution, exists := s.instances[agentExecutionID] s.mu.Unlock() if !exists { - return nil, fmt.Errorf("agent instance %q not found", agentInstanceID) + return nil, fmt.Errorf("agent execution %q not found", agentExecutionID) } - instance.statusMu.Lock() - status := instance.status - instance.statusMu.Unlock() + execution.statusMu.Lock() + status := execution.status + execution.statusMu.Unlock() - return &v1.AgentInstance{ - ID: instance.id, - TaskID: instance.taskID, - AgentProfileID: instance.agentProfileID, + return &v1.AgentExecution{ + ID: execution.id, + TaskID: execution.taskID, + AgentProfileID: execution.agentProfileID, Status: status, }, nil } @@ -322,13 +329,13 @@ func (s *SimulatedAgentManagerClient) ListAgentTypes(ctx context.Context) ([]*v1 } // PromptAgent sends a follow-up prompt to a running agent -func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentInstanceID string, prompt string) (*executor.PromptResult, error) { +func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*executor.PromptResult, error) { s.mu.Lock() - instance, exists := s.instances[agentInstanceID] + execution, exists := s.instances[agentExecutionID] s.mu.Unlock() if !exists { - return nil, fmt.Errorf("agent instance %q not found", agentInstanceID) + return nil, fmt.Errorf("agent execution %q not found", agentExecutionID) } // Simulate receiving prompt and generating response @@ -337,7 +344,7 @@ func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentInst msg := protocol.Message{ Type: protocol.MessageTypeLog, - TaskID: instance.taskID, + TaskID: execution.taskID, Timestamp: time.Now(), Data: map[string]interface{}{ "level": "info", @@ -353,7 +360,7 @@ func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentInst } event := bus.NewEvent(events.ACPMessage, "simulated-agent", msgData) - subject := events.BuildACPSubject(instance.taskID) + subject := events.BuildACPSubject(execution.taskID) if err := s.eventBus.Publish(context.Background(), subject, event); err != nil { s.logger.Warn("failed to publish simulated ACP message", zap.Error(err)) } @@ -375,31 +382,31 @@ func (s *SimulatedAgentManagerClient) RespondToPermissionByTaskID(ctx context.Co } // CompleteAgent marks an agent as completed -func (s *SimulatedAgentManagerClient) CompleteAgent(instanceID string) { +func (s *SimulatedAgentManagerClient) CompleteAgent(executionID string) { s.mu.Lock() - instance, exists := s.instances[instanceID] + execution, exists := s.instances[executionID] s.mu.Unlock() if !exists { return } - instance.status = v1.AgentStatusCompleted - s.publishAgentEvent(events.AgentCompleted, instance) + execution.status = v1.AgentStatusCompleted + s.publishAgentEvent(events.AgentCompleted, execution) } // FailAgent marks an agent as failed -func (s *SimulatedAgentManagerClient) FailAgent(instanceID string, reason string) { +func (s *SimulatedAgentManagerClient) FailAgent(executionID string, reason string) { s.mu.Lock() - instance, exists := s.instances[instanceID] + execution, exists := s.instances[executionID] s.mu.Unlock() if !exists { return } - instance.status = v1.AgentStatusFailed - s.publishAgentEvent(events.AgentFailed, instance) + execution.status = v1.AgentStatusFailed + s.publishAgentEvent(events.AgentFailed, execution) } // GetLaunchCount returns the number of times LaunchAgent was called @@ -412,8 +419,8 @@ func (s *SimulatedAgentManagerClient) Close() { close(s.stopCh) } -// GetRecoveredInstances returns recovered instances (none for simulated agent) -func (s *SimulatedAgentManagerClient) GetRecoveredInstances() []executor.RecoveredInstanceInfo { +// GetRecoveredExecutions returns recovered executions (none for simulated agent) +func (s *SimulatedAgentManagerClient) GetRecoveredExecutions() []executor.RecoveredExecutionInfo { return nil } @@ -430,6 +437,10 @@ func (s *SimulatedAgentManagerClient) IsAgentRunningForTask(ctx context.Context, return false } +func (s *SimulatedAgentManagerClient) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { + return nil +} + // ============================================ // ORCHESTRATOR TEST SERVER // ============================================ @@ -863,7 +874,7 @@ func TestOrchestratorStartTask(t *testing.T) { assert.True(t, payload["success"].(bool)) assert.Equal(t, taskID, payload["task_id"]) - assert.NotEmpty(t, payload["agent_instance_id"]) + assert.NotEmpty(t, payload["agent_execution_id"]) // Wait for ACP notifications time.Sleep(500 * time.Millisecond) @@ -1087,7 +1098,7 @@ func TestOrchestratorACPStreaming(t *testing.T) { defer ts.Close() // Configure simulated agent to emit specific ACP messages - ts.AgentManager.SetACPMessageFn(func(taskID, instanceID string) []protocol.Message { + ts.AgentManager.SetACPMessageFn(func(taskID, executionID string) []protocol.Message { return []protocol.Message{ { Type: protocol.MessageTypeProgress, @@ -1438,8 +1449,8 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { var startPayload map[string]interface{} require.NoError(t, startResp.ParsePayload(&startPayload)) assert.True(t, startPayload["success"].(bool)) - agentInstanceID := startPayload["agent_instance_id"].(string) - assert.NotEmpty(t, agentInstanceID) + agentExecutionID := startPayload["agent_execution_id"].(string) + assert.NotEmpty(t, agentExecutionID) // 6. Collect notifications during execution time.Sleep(400 * time.Millisecond) diff --git a/apps/backend/internal/orchestrator/controller/controller.go b/apps/backend/internal/orchestrator/controller/controller.go index f62193f034..def3c7fda5 100644 --- a/apps/backend/internal/orchestrator/controller/controller.go +++ b/apps/backend/internal/orchestrator/controller/controller.go @@ -68,11 +68,11 @@ func (c *Controller) StartTask(ctx context.Context, req dto.StartTaskRequest) (d } resp := dto.StartTaskResponse{ - Success: true, - TaskID: execution.TaskID, - AgentInstanceID: execution.AgentInstanceID, - TaskSessionID: execution.SessionID, - State: string(execution.SessionState), + Success: true, + TaskID: execution.TaskID, + AgentExecutionID: execution.AgentExecutionID, + TaskSessionID: execution.SessionID, + State: string(execution.SessionState), } // Include worktree info if available @@ -94,11 +94,11 @@ func (c *Controller) ResumeTaskSession(ctx context.Context, req dto.ResumeTaskSe } resp := dto.ResumeTaskSessionResponse{ - Success: true, - TaskID: execution.TaskID, - AgentInstanceID: execution.AgentInstanceID, - TaskSessionID: execution.SessionID, - State: string(execution.SessionState), + Success: true, + TaskID: execution.TaskID, + AgentExecutionID: execution.AgentExecutionID, + TaskSessionID: execution.SessionID, + State: string(execution.SessionState), } if execution.WorktreePath != "" { @@ -111,6 +111,19 @@ func (c *Controller) ResumeTaskSession(ctx context.Context, req dto.ResumeTaskSe return resp, nil } +// GetTaskSessionStatus returns the status of a task session including whether it's resumable +func (c *Controller) GetTaskSessionStatus(ctx context.Context, req dto.TaskSessionStatusRequest) (dto.TaskSessionStatusResponse, error) { + status, err := c.service.GetTaskSessionStatus(ctx, req.TaskID, req.TaskSessionID) + if err != nil { + return dto.TaskSessionStatusResponse{ + SessionID: req.TaskSessionID, + TaskID: req.TaskID, + Error: err.Error(), + }, nil + } + return status, nil +} + // StopTask stops task execution func (c *Controller) StopTask(ctx context.Context, req dto.StopTaskRequest) (dto.SuccessResponse, error) { reason := req.Reason @@ -174,13 +187,13 @@ func (c *Controller) GetTaskExecution(ctx context.Context, req dto.GetTaskExecut } return dto.TaskExecutionResponse{ - HasExecution: true, - TaskID: execution.TaskID, - AgentInstanceID: execution.AgentInstanceID, - AgentProfileID: execution.AgentProfileID, - TaskSessionID: execution.SessionID, - State: string(execution.SessionState), - Progress: execution.Progress, - StartedAt: execution.StartedAt.Format("2006-01-02T15:04:05Z"), + HasExecution: true, + TaskID: execution.TaskID, + AgentExecutionID: execution.AgentExecutionID, + AgentProfileID: execution.AgentProfileID, + TaskSessionID: execution.SessionID, + State: string(execution.SessionState), + Progress: execution.Progress, + StartedAt: execution.StartedAt.Format("2006-01-02T15:04:05Z"), }, nil } diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index d248326cd8..e4d53b9432 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -49,13 +49,13 @@ type StartTaskRequest struct { // StartTaskResponse is the response for orchestrator.start type StartTaskResponse struct { - Success bool `json:"success"` - TaskID string `json:"task_id"` - AgentInstanceID string `json:"agent_instance_id"` - TaskSessionID string `json:"task_session_id,omitempty"` - State string `json:"state"` - WorktreePath *string `json:"worktree_path,omitempty"` - WorktreeBranch *string `json:"worktree_branch,omitempty"` + Success bool `json:"success"` + TaskID string `json:"task_id"` + AgentExecutionID string `json:"agent_execution_id"` + TaskSessionID string `json:"task_session_id,omitempty"` + State string `json:"state"` + WorktreePath *string `json:"worktree_path,omitempty"` + WorktreeBranch *string `json:"worktree_branch,omitempty"` } // ResumeTaskSessionRequest is the payload for task.session.resume @@ -66,13 +66,44 @@ type ResumeTaskSessionRequest struct { // ResumeTaskSessionResponse is the response for task.session.resume type ResumeTaskSessionResponse struct { - Success bool `json:"success"` - TaskID string `json:"task_id"` - AgentInstanceID string `json:"agent_instance_id"` - TaskSessionID string `json:"task_session_id,omitempty"` - State string `json:"state"` - WorktreePath *string `json:"worktree_path,omitempty"` - WorktreeBranch *string `json:"worktree_branch,omitempty"` + Success bool `json:"success"` + TaskID string `json:"task_id"` + AgentExecutionID string `json:"agent_execution_id"` + TaskSessionID string `json:"task_session_id,omitempty"` + State string `json:"state"` + WorktreePath *string `json:"worktree_path,omitempty"` + WorktreeBranch *string `json:"worktree_branch,omitempty"` +} + +// TaskSessionStatusRequest is the payload for task.session.status +type TaskSessionStatusRequest struct { + TaskID string `json:"task_id"` + TaskSessionID string `json:"task_session_id"` +} + +// TaskSessionStatusResponse is the response for task.session.status +type TaskSessionStatusResponse struct { + // Session metadata + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + State string `json:"state"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + + // Runtime status + IsAgentRunning bool `json:"is_agent_running"` // Agent process is currently running + IsResumable bool `json:"is_resumable"` // Session can be resumed + NeedsResume bool `json:"needs_resume"` // Session needs resumption (page reload scenario) + ResumeReason string `json:"resume_reason,omitempty"` // Why resume is needed (e.g., "agent_not_running") + + // ACP session info + ACPSessionID string `json:"acp_session_id,omitempty"` + + // Worktree info + WorktreePath *string `json:"worktree_path,omitempty"` + WorktreeBranch *string `json:"worktree_branch,omitempty"` + + // Error info + Error string `json:"error,omitempty"` } // StopTaskRequest is the payload for orchestrator.stop @@ -132,12 +163,12 @@ type GetTaskExecutionRequest struct { // TaskExecutionResponse is the response for task.execution type TaskExecutionResponse struct { - HasExecution bool `json:"has_execution"` - TaskID string `json:"task_id"` - AgentInstanceID string `json:"agent_instance_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - TaskSessionID string `json:"task_session_id,omitempty"` - State string `json:"state,omitempty"` - Progress int `json:"progress,omitempty"` - StartedAt string `json:"started_at,omitempty"` + HasExecution bool `json:"has_execution"` + TaskID string `json:"task_id"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + TaskSessionID string `json:"task_session_id,omitempty"` + State string `json:"state,omitempty"` + Progress int `json:"progress,omitempty"` + StartedAt string `json:"started_at,omitempty"` } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index ff6225963a..0d6e1fc50c 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -33,31 +33,39 @@ type PromptResult struct { // AgentManagerClient is an interface for the Agent Manager service // This will be implemented via gRPC or HTTP client type AgentManagerClient interface { - // LaunchAgent starts a new agent container for a task + // LaunchAgent creates a new agentctl instance for a task (agent not started yet) LaunchAgent(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) - // StopAgent stops a running agent - StopAgent(ctx context.Context, agentInstanceID string, force bool) error + // StartAgentProcess starts the agent subprocess for an execution. + // The command is built internally based on the execution's agent profile. + StartAgentProcess(ctx context.Context, agentExecutionID string) error - // GetAgentStatus returns the status of an agent instance - GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) + // StopAgent stops a running agent + StopAgent(ctx context.Context, agentExecutionID string, force bool) error + + // GetAgentStatus returns the status of an agent execution + GetAgentStatus(ctx context.Context, agentExecutionID string) (*v1.AgentExecution, error) // ListAgentTypes returns available agent types ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) // PromptAgent sends a prompt to a running agent // Returns PromptResult indicating if the agent needs input - PromptAgent(ctx context.Context, agentInstanceID string, prompt string) (*PromptResult, error) + PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*PromptResult, error) // RespondToPermission sends a response to a permission request RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error - // GetRecoveredInstances returns instances recovered from Docker during startup - GetRecoveredInstances() []RecoveredInstanceInfo + // GetRecoveredExecutions returns executions recovered from Docker during startup + GetRecoveredExecutions() []RecoveredExecutionInfo // IsAgentRunningForTask checks if an agent is actually running for a task // This probes the actual agent (Docker container or standalone process) rather than relying on cached state IsAgentRunningForTask(ctx context.Context, taskID string) bool + + // CleanupStaleExecutionByTaskID removes a stale agent execution from tracking without trying to stop it. + // This is used when we detect the agent process has stopped but the execution is still tracked. + CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error } // LaunchAgentRequest contains parameters for launching an agent @@ -83,23 +91,23 @@ type LaunchAgentRequest struct { // LaunchAgentResponse contains the result of launching an agent type LaunchAgentResponse struct { - AgentInstanceID string - ContainerID string - Status v1.AgentStatus - WorktreeID string - WorktreePath string - WorktreeBranch string + AgentExecutionID string + ContainerID string + Status v1.AgentStatus + WorktreeID string + WorktreePath string + WorktreeBranch string } // TaskExecution tracks an active task execution (kept for API compatibility) type TaskExecution struct { - TaskID string - AgentInstanceID string - AgentProfileID string - StartedAt time.Time - SessionState v1.TaskSessionState - Progress int - LastUpdate time.Time + TaskID string + AgentExecutionID string + AgentProfileID string + StartedAt time.Time + SessionState v1.TaskSessionState + Progress int + LastUpdate time.Time // SessionID is the database ID of the agent session SessionID string // Worktree info for the agent @@ -110,14 +118,14 @@ type TaskExecution struct { // FromTaskSession converts a models.TaskSession to TaskExecution func FromTaskSession(s *models.TaskSession) *TaskExecution { execution := &TaskExecution{ - TaskID: s.TaskID, - AgentInstanceID: s.AgentInstanceID, - AgentProfileID: s.AgentProfileID, - StartedAt: s.StartedAt, - SessionState: agentSessionStateToV1(s.State), - Progress: s.Progress, - LastUpdate: s.UpdatedAt, - SessionID: s.ID, + TaskID: s.TaskID, + AgentExecutionID: s.AgentExecutionID, + AgentProfileID: s.AgentProfileID, + StartedAt: s.StartedAt, + SessionState: agentSessionStateToV1(s.State), + Progress: s.Progress, + LastUpdate: s.UpdatedAt, + SessionID: s.ID, } if len(s.Worktrees) > 0 { execution.WorktreePath = s.Worktrees[0].WorktreePath @@ -202,40 +210,40 @@ func (e *Executor) LoadActiveSessionsFromDB(ctx context.Context) error { return nil } -// RecoveredInstanceInfo contains info about an instance recovered from Docker -type RecoveredInstanceInfo struct { - InstanceID string +// RecoveredExecutionInfo contains info about an execution recovered from Docker +type RecoveredExecutionInfo struct { + ExecutionID string TaskID string ContainerID string AgentProfileID string } -// SyncWithRecoveredInstances ensures the executor's cache is in sync with -// instances recovered from Docker by the lifecycle manager. -// For each recovered instance, if not already in cache, add it. -func (e *Executor) SyncWithRecoveredInstances(ctx context.Context, instances []RecoveredInstanceInfo) { +// SyncWithRecoveredExecutions ensures the executor's cache is in sync with +// executions recovered from Docker by the lifecycle manager. +// For each recovered execution, if not already in cache, add it. +func (e *Executor) SyncWithRecoveredExecutions(ctx context.Context, executions []RecoveredExecutionInfo) { e.mu.Lock() defer e.mu.Unlock() - for _, inst := range instances { - if _, exists := e.executions[inst.TaskID]; exists { + for _, exec := range executions { + if _, exists := e.executions[exec.TaskID]; exists { continue // Already have this execution } - // Add to cache - this instance is running in Docker but wasn't in DB as active - e.executions[inst.TaskID] = &TaskExecution{ - TaskID: inst.TaskID, - AgentInstanceID: inst.InstanceID, - AgentProfileID: inst.AgentProfileID, - StartedAt: time.Now(), // We don't know exact start time - SessionState: v1.TaskSessionStateRunning, - Progress: 0, - LastUpdate: time.Now(), + // Add to cache - this execution is running in Docker but wasn't in DB as active + e.executions[exec.TaskID] = &TaskExecution{ + TaskID: exec.TaskID, + AgentExecutionID: exec.ExecutionID, + AgentProfileID: exec.AgentProfileID, + StartedAt: time.Now(), // We don't know exact start time + SessionState: v1.TaskSessionStateRunning, + Progress: 0, + LastUpdate: time.Now(), } - e.logger.Info("synced recovered instance to executor cache", - zap.String("task_id", inst.TaskID), - zap.String("instance_id", inst.InstanceID)) + e.logger.Info("synced recovered execution to executor cache", + zap.String("task_id", exec.TaskID), + zap.String("execution_id", exec.ExecutionID)) } } @@ -361,7 +369,7 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP return nil, err } - session.AgentInstanceID = resp.AgentInstanceID + session.AgentExecutionID = resp.AgentExecutionID session.ContainerID = resp.ContainerID session.State = models.TaskSessionStateStarting session.Progress = 0 @@ -395,26 +403,39 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP // Track the execution in the in-memory cache execution := &TaskExecution{ - TaskID: task.ID, - AgentInstanceID: resp.AgentInstanceID, - AgentProfileID: agentProfileID, - StartedAt: now, - SessionState: v1.TaskSessionStateStarting, - Progress: 0, - LastUpdate: now, - SessionID: sessionID, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, + TaskID: task.ID, + AgentExecutionID: resp.AgentExecutionID, + AgentProfileID: agentProfileID, + StartedAt: now, + SessionState: v1.TaskSessionStateStarting, + Progress: 0, + LastUpdate: now, + SessionID: sessionID, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, } e.mu.Lock() e.executions[task.ID] = execution e.mu.Unlock() + // Start the agent process (agentctl execution was created above) + go func() { + startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + if err := e.agentManager.StartAgentProcess(startCtx, resp.AgentExecutionID); err != nil { + e.logger.Error("failed to start agent process", + zap.String("task_id", task.ID), + zap.String("agent_execution_id", resp.AgentExecutionID), + zap.Error(err)) + } + }() + e.logger.Info("agent launched successfully", zap.String("task_id", task.ID), zap.String("session_id", sessionID), - zap.String("agent_instance_id", resp.AgentInstanceID), + zap.String("agent_execution_id", resp.AgentExecutionID), zap.String("container_id", resp.ContainerID), zap.String("worktree_path", resp.WorktreePath), zap.String("worktree_branch", resp.WorktreeBranch)) @@ -510,6 +531,9 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo if session.Metadata != nil { if acpSessionID, ok := session.Metadata["acp_session_id"].(string); ok && acpSessionID != "" { req.ACPSessionID = acpSessionID + e.logger.Info("found acp_session_id in session metadata for resumption", + zap.String("task_id", task.ID), + zap.String("acp_session_id", acpSessionID)) } } @@ -517,6 +541,7 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo zap.String("task_id", task.ID), zap.String("session_id", session.ID), zap.String("agent_profile_id", session.AgentProfileID), + zap.String("acp_session_id", req.ACPSessionID), zap.Bool("use_worktree", req.UseWorktree)) resp, err := e.agentManager.LaunchAgent(ctx, req) @@ -528,7 +553,7 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo return nil, err } - session.AgentInstanceID = resp.AgentInstanceID + session.AgentExecutionID = resp.AgentExecutionID session.ContainerID = resp.ContainerID session.Progress = 0 session.ErrorMessage = "" @@ -578,22 +603,36 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo now := time.Now().UTC() execution := &TaskExecution{ - TaskID: task.ID, - AgentInstanceID: resp.AgentInstanceID, - AgentProfileID: session.AgentProfileID, - StartedAt: now, - SessionState: v1.TaskSessionStateStarting, - Progress: 0, - LastUpdate: now, - SessionID: session.ID, - WorktreePath: worktreePath, - WorktreeBranch: worktreeBranch, + TaskID: task.ID, + AgentExecutionID: resp.AgentExecutionID, + AgentProfileID: session.AgentProfileID, + StartedAt: now, + SessionState: v1.TaskSessionStateStarting, + Progress: 0, + LastUpdate: now, + SessionID: session.ID, + WorktreePath: worktreePath, + WorktreeBranch: worktreeBranch, } e.mu.Lock() e.executions[task.ID] = execution e.mu.Unlock() + // Start the agent process (agentctl execution was created above) + go func() { + startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + if err := e.agentManager.StartAgentProcess(startCtx, resp.AgentExecutionID); err != nil { + e.logger.Error("failed to start agent process on resume", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID), + zap.String("agent_execution_id", resp.AgentExecutionID), + zap.Error(err)) + } + }() + return execution, nil } @@ -606,11 +645,11 @@ func (e *Executor) Stop(ctx context.Context, taskID string, reason string, force e.logger.Info("stopping execution", zap.String("task_id", taskID), - zap.String("agent_instance_id", execution.AgentInstanceID), + zap.String("agent_execution_id", execution.AgentExecutionID), zap.String("reason", reason), zap.Bool("force", force)) - err = e.agentManager.StopAgent(ctx, execution.AgentInstanceID, force) + err = e.agentManager.StopAgent(ctx, execution.AgentExecutionID, force) if err != nil { // Log the error but continue to clean up execution state // The agent instance may already be gone (container stopped externally) @@ -651,10 +690,10 @@ func (e *Executor) Prompt(ctx context.Context, taskID string, prompt string) (*P e.logger.Info("sending prompt to agent", zap.String("task_id", taskID), - zap.String("agent_instance_id", execution.AgentInstanceID), + zap.String("agent_execution_id", execution.AgentExecutionID), zap.Int("prompt_length", len(prompt))) - return e.agentManager.PromptAgent(ctx, execution.AgentInstanceID, prompt) + return e.agentManager.PromptAgent(ctx, execution.AgentExecutionID, prompt) } // getOrLoadExecution gets execution from memory cache or loads from database @@ -722,6 +761,13 @@ func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { if execution.SessionID != "" { _ = e.repo.UpdateTaskSessionState(ctx, execution.SessionID, models.TaskSessionStateCancelled, "agent process stopped") } + + // Clean up the stale execution from the agent manager + if err := e.agentManager.CleanupStaleExecutionByTaskID(ctx, taskID); err != nil { + e.logger.Warn("failed to cleanup stale agent execution", + zap.String("task_id", taskID), + zap.Error(err)) + } return nil, false } @@ -744,6 +790,13 @@ func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) { zap.String("task_id", taskID), zap.String("session_id", session.ID)) _ = e.repo.UpdateTaskSessionState(ctx, session.ID, models.TaskSessionStateCancelled, "agent not running after backend restart") + + // Clean up the stale execution from the agent manager + if err := e.agentManager.CleanupStaleExecutionByTaskID(ctx, taskID); err != nil { + e.logger.Warn("failed to cleanup stale agent execution", + zap.String("task_id", taskID), + zap.Error(err)) + } return nil, false } @@ -775,6 +828,13 @@ func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) ( if execution.SessionID != "" { _ = e.repo.UpdateTaskSessionState(ctx, execution.SessionID, models.TaskSessionStateCancelled, "agent process stopped") } + + // Clean up the stale execution from the agent manager + if err := e.agentManager.CleanupStaleExecutionByTaskID(ctx, taskID); err != nil { + e.logger.Warn("failed to cleanup stale agent execution", + zap.String("task_id", taskID), + zap.Error(err)) + } return nil, false } @@ -794,6 +854,13 @@ func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) ( zap.String("task_id", taskID), zap.String("session_id", session.ID)) _ = e.repo.UpdateTaskSessionState(ctx, session.ID, models.TaskSessionStateCancelled, "agent not running after backend restart") + + // Clean up the stale execution from the agent manager + if err := e.agentManager.CleanupStaleExecutionByTaskID(ctx, taskID); err != nil { + e.logger.Warn("failed to cleanup stale agent execution", + zap.String("task_id", taskID), + zap.Error(err)) + } return nil, false } @@ -958,27 +1025,34 @@ func (m *MockAgentManagerClient) LaunchAgent(ctx context.Context, req *LaunchAge zap.String("branch", req.Branch)) return &LaunchAgentResponse{ - AgentInstanceID: uuid.New().String(), - ContainerID: "mock-container-" + uuid.New().String()[:8], - Status: v1.AgentStatusStarting, + AgentExecutionID: uuid.New().String(), + ContainerID: "mock-container-" + uuid.New().String()[:8], + Status: v1.AgentStatusStarting, }, nil } +// StartAgentProcess mocks starting the agent subprocess +func (m *MockAgentManagerClient) StartAgentProcess(ctx context.Context, agentExecutionID string) error { + m.logger.Info("mock: starting agent process", + zap.String("agent_execution_id", agentExecutionID)) + return nil +} + // StopAgent mocks stopping an agent -func (m *MockAgentManagerClient) StopAgent(ctx context.Context, agentInstanceID string, force bool) error { +func (m *MockAgentManagerClient) StopAgent(ctx context.Context, agentExecutionID string, force bool) error { m.logger.Info("mock: stopping agent", - zap.String("agent_instance_id", agentInstanceID), + zap.String("agent_execution_id", agentExecutionID), zap.Bool("force", force)) return nil } // GetAgentStatus mocks getting agent status -func (m *MockAgentManagerClient) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) { +func (m *MockAgentManagerClient) GetAgentStatus(ctx context.Context, agentExecutionID string) (*v1.AgentExecution, error) { m.logger.Info("mock: getting agent status", - zap.String("agent_instance_id", agentInstanceID)) + zap.String("agent_execution_id", agentExecutionID)) - return &v1.AgentInstance{ - ID: agentInstanceID, + return &v1.AgentExecution{ + ID: agentExecutionID, Status: v1.AgentStatusRunning, AgentProfileID: "mock-agent", }, nil @@ -1001,9 +1075,9 @@ func (m *MockAgentManagerClient) ListAgentTypes(ctx context.Context) ([]*v1.Agen } // PromptAgent mocks sending a prompt to an agent -func (m *MockAgentManagerClient) PromptAgent(ctx context.Context, agentInstanceID string, prompt string) (*PromptResult, error) { +func (m *MockAgentManagerClient) PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*PromptResult, error) { m.logger.Info("mock: prompting agent", - zap.String("agent_instance_id", agentInstanceID), + zap.String("agent_execution_id", agentExecutionID), zap.Int("prompt_length", len(prompt))) return &PromptResult{StopReason: "end_turn"}, nil } @@ -1018,8 +1092,8 @@ func (m *MockAgentManagerClient) RespondToPermissionByTaskID(ctx context.Context return nil } -// GetRecoveredInstances mocks getting recovered instances (returns empty for mock) -func (m *MockAgentManagerClient) GetRecoveredInstances() []RecoveredInstanceInfo { +// GetRecoveredExecutions mocks getting recovered executions (returns empty for mock) +func (m *MockAgentManagerClient) GetRecoveredExecutions() []RecoveredExecutionInfo { return nil } @@ -1027,3 +1101,8 @@ func (m *MockAgentManagerClient) GetRecoveredInstances() []RecoveredInstanceInfo func (m *MockAgentManagerClient) IsAgentRunningForTask(ctx context.Context, taskID string) bool { return false } + +// CleanupStaleExecutionByTaskID mocks cleaning up a stale execution (no-op for mock) +func (m *MockAgentManagerClient) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { + return nil +} diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index d2e5ed78a5..6b5b50969a 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -37,6 +37,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionPermissionRespond, h.wsRespondToPermission) d.RegisterFunc(ws.ActionTaskExecution, h.wsGetTaskExecution) d.RegisterFunc(ws.ActionTaskSessionResume, h.wsResumeTaskSession) + d.RegisterFunc(ws.ActionTaskSessionStatus, h.wsGetTaskSessionStatus) } // WS handlers @@ -281,3 +282,35 @@ func (h *Handlers) wsGetTaskExecution(ctx context.Context, msg *ws.Message) (*ws } return ws.NewResponse(msg.ID, msg.Action, resp) } + +type wsGetTaskSessionStatusRequest struct { + TaskID string `json:"task_id"` + TaskSessionID string `json:"task_session_id"` +} + +func (h *Handlers) wsGetTaskSessionStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetTaskSessionStatusRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid request payload", nil) + } + + if req.TaskID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "task_id is required", nil) + } + if req.TaskSessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "task_session_id is required", nil) + } + + resp, err := h.controller.GetTaskSessionStatus(ctx, dto.TaskSessionStatusRequest{ + TaskID: req.TaskID, + TaskSessionID: req.TaskSessionID, + }) + if err != nil { + h.logger.Error("failed to get task session status", + zap.String("task_id", req.TaskID), + zap.String("task_session_id", req.TaskSessionID), + zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task session status: "+err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index bb397e6b9c..ccdbb29655 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -35,19 +35,23 @@ func (m *mockAgentManager) LaunchAgent(ctx context.Context, req *executor.Launch m.mu.Unlock() return &executor.LaunchAgentResponse{ - AgentInstanceID: "agent-" + req.TaskID, - ContainerID: "container-" + req.TaskID, - Status: v1.AgentStatusRunning, + AgentExecutionID: "agent-" + req.TaskID, + ContainerID: "container-" + req.TaskID, + Status: v1.AgentStatusRunning, }, nil } -func (m *mockAgentManager) StopAgent(ctx context.Context, agentInstanceID string, force bool) error { +func (m *mockAgentManager) StartAgentProcess(ctx context.Context, agentExecutionID string) error { return nil } -func (m *mockAgentManager) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentInstance, error) { - return &v1.AgentInstance{ - ID: agentInstanceID, +func (m *mockAgentManager) StopAgent(ctx context.Context, agentExecutionID string, force bool) error { + return nil +} + +func (m *mockAgentManager) GetAgentStatus(ctx context.Context, agentExecutionID string) (*v1.AgentExecution, error) { + return &v1.AgentExecution{ + ID: agentExecutionID, Status: v1.AgentStatusRunning, }, nil } @@ -56,7 +60,7 @@ func (m *mockAgentManager) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, return []*v1.AgentType{}, nil } -func (m *mockAgentManager) PromptAgent(ctx context.Context, agentInstanceID string, prompt string) (*executor.PromptResult, error) { +func (m *mockAgentManager) PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*executor.PromptResult, error) { return &executor.PromptResult{StopReason: "end_turn"}, nil } @@ -64,7 +68,7 @@ func (m *mockAgentManager) RespondToPermissionByTaskID(ctx context.Context, task return nil } -func (m *mockAgentManager) GetRecoveredInstances() []executor.RecoveredInstanceInfo { +func (m *mockAgentManager) GetRecoveredExecutions() []executor.RecoveredExecutionInfo { return nil } @@ -72,6 +76,10 @@ func (m *mockAgentManager) IsAgentRunningForTask(ctx context.Context, taskID str return false } +func (m *mockAgentManager) CleanupStaleExecutionByTaskID(ctx context.Context, taskID string) error { + return nil +} + // testTaskRepository is an in-memory task repository for testing type testTaskRepository struct { tasks map[string]*v1.Task diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 85be02c04e..ac6fb30692 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -14,6 +14,7 @@ import ( "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/orchestrator/dto" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/queue" "github.com/kandev/kandev/internal/orchestrator/scheduler" @@ -188,12 +189,12 @@ func (s *Service) Start(ctx context.Context) error { s.logger.Warn("failed to load active sessions from database (non-fatal)", zap.Error(err)) } - // Sync with instances recovered from Docker by the lifecycle manager + // Sync with executions recovered from Docker by the lifecycle manager // This ensures we track containers that are running but weren't in the DB as active - recovered := s.agentManager.GetRecoveredInstances() + recovered := s.agentManager.GetRecoveredExecutions() if len(recovered) > 0 { - s.logger.Info("syncing with recovered Docker instances", zap.Int("count", len(recovered))) - s.executor.SyncWithRecoveredInstances(ctx, recovered) + s.logger.Info("syncing with recovered Docker executions", zap.Int("count", len(recovered))) + s.executor.SyncWithRecoveredExecutions(ctx, recovered) } // Start the watcher first to begin receiving events @@ -400,6 +401,99 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin return execution, nil } +// GetActiveSessionID returns the active (running) session ID for a task +func (s *Service) GetActiveSessionID(ctx context.Context, taskID string) (string, error) { + return s.executor.GetActiveSessionID(ctx, taskID) +} + +// GetTaskSessionStatus returns the status of a task session including whether it's resumable +func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID string) (dto.TaskSessionStatusResponse, error) { + s.logger.Info("checking task session status", + zap.String("task_id", taskID), + zap.String("session_id", sessionID)) + + resp := dto.TaskSessionStatusResponse{ + SessionID: sessionID, + TaskID: taskID, + } + + // 1. Load session from database + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + resp.Error = "session not found" + return resp, nil + } + + if session.TaskID != taskID { + resp.Error = "session does not belong to task" + return resp, nil + } + + resp.State = string(session.State) + resp.AgentProfileID = session.AgentProfileID + + // Extract ACP session ID from metadata - this is the key requirement for resumption + var acpSessionID string + if session.Metadata != nil { + if id, ok := session.Metadata["acp_session_id"].(string); ok { + acpSessionID = id + resp.ACPSessionID = acpSessionID + } + } + + // Extract worktree info + if len(session.Worktrees) > 0 { + wt := session.Worktrees[0] + if wt.WorktreePath != "" { + resp.WorktreePath = &wt.WorktreePath + } + if wt.WorktreeBranch != "" { + resp.WorktreeBranch = &wt.WorktreeBranch + } + } + + // 2. Check if we have an active execution in memory and agent is running + if exec, ok := s.executor.GetExecutionWithContext(ctx, taskID); ok && exec != nil { + resp.IsAgentRunning = true + resp.IsResumable = true + resp.NeedsResume = false + return resp, nil + } + + // 3. Session can be resumed if it has an ACP session ID + // Any session type with an acp_session_id can be resumed + if acpSessionID == "" { + resp.IsAgentRunning = false + resp.IsResumable = false + resp.NeedsResume = false + resp.Error = "session has no acp_session_id" + return resp, nil + } + + // 4. Additional validations for resumption + if session.AgentProfileID == "" { + resp.Error = "session missing agent profile" + resp.IsResumable = false + return resp, nil + } + + // Check if worktree exists (if one was used) + if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreePath != "" { + if _, err := os.Stat(session.Worktrees[0].WorktreePath); err != nil { + resp.Error = "worktree not found" + resp.IsResumable = false + return resp, nil + } + } + + resp.IsAgentRunning = false + resp.IsResumable = true + resp.NeedsResume = true + resp.ResumeReason = "agent_not_running" + + return resp, nil +} + // StopTask stops agent execution for a task func (s *Service) StopTask(ctx context.Context, taskID string, reason string, force bool) error { s.logger.Info("stopping task execution", @@ -534,7 +628,7 @@ func (s *Service) handleTaskStateChanged(ctx context.Context, data watcher.TaskE func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventData) { s.logger.Info("handling agent ready", zap.String("task_id", data.TaskID), - zap.String("agent_instance_id", data.AgentInstanceID)) + zap.String("agent_execution_id", data.AgentExecutionID)) if s.markAgentReady(data.TaskID) { s.finalizeAgentReady(data.TaskID, "") @@ -596,7 +690,7 @@ func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPS func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) { s.logger.Info("handling agent completed", zap.String("task_id", data.TaskID), - zap.String("agent_instance_id", data.AgentInstanceID)) + zap.String("agent_execution_id", data.AgentExecutionID)) // Update scheduler and remove from queue s.scheduler.HandleTaskCompleted(data.TaskID, true) @@ -618,7 +712,7 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) { s.logger.Warn("handling agent failed", zap.String("task_id", data.TaskID), - zap.String("agent_instance_id", data.AgentInstanceID), + zap.String("agent_execution_id", data.AgentExecutionID), zap.String("error_message", data.ErrorMessage)) // Trigger retry logic diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index 347f163837..8341fd2aa1 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -26,18 +26,18 @@ type TaskEventData struct { // AgentEventData contains data from agent events type AgentEventData struct { - TaskID string `json:"task_id"` - AgentInstanceID string `json:"agent_instance_id"` - AgentProfileID string `json:"agent_profile_id"` - ExitCode *int `json:"exit_code,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` + TaskID string `json:"task_id"` + AgentExecutionID string `json:"agent_execution_id"` + AgentProfileID string `json:"agent_profile_id"` + ExitCode *int `json:"exit_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` } // ACPSessionEventData contains data from ACP session events type ACPSessionEventData struct { - TaskID string `json:"task_id"` - AgentInstanceID string `json:"agent_instance_id"` - ACPSessionID string `json:"acp_session_id"` + TaskID string `json:"task_id"` + AgentExecutionID string `json:"agent_execution_id"` + ACPSessionID string `json:"acp_session_id"` } // PromptCompleteData contains data from prompt_complete events @@ -360,7 +360,7 @@ func (w *Watcher) createAgentEventHandler(handler func(ctx context.Context, data w.logger.Debug("Handling agent event", zap.String("event_type", event.Type), zap.String("task_id", data.TaskID), - zap.String("agent_instance_id", data.AgentInstanceID)) + zap.String("agent_execution_id", data.AgentExecutionID)) handler(ctx, data) return nil diff --git a/apps/backend/internal/orchestrator/watcher/watcher_test.go b/apps/backend/internal/orchestrator/watcher/watcher_test.go index a7628419d2..5a2887df10 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher_test.go +++ b/apps/backend/internal/orchestrator/watcher/watcher_test.go @@ -275,10 +275,10 @@ func TestAgentEventHandling(t *testing.T) { // Simulate publishing an agent completed event exitCode := 0 event := bus.NewEvent(events.AgentCompleted, "test", map[string]interface{}{ - "task_id": "task-123", - "agent_instance_id": "agent-456", - "agent_type": "test-agent", - "exit_code": exitCode, + "task_id": "task-123", + "agent_execution_id": "agent-456", + "agent_type": "test-agent", + "exit_code": exitCode, }) _ = eventBus.Publish(context.Background(), events.AgentCompleted, event) @@ -294,8 +294,8 @@ func TestAgentEventHandling(t *testing.T) { if receivedData.TaskID != "task-123" { t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID) } - if receivedData.AgentInstanceID != "agent-456" { - t.Errorf("expected agent_instance_id = 'agent-456', got %s", receivedData.AgentInstanceID) + if receivedData.AgentExecutionID != "agent-456" { + t.Errorf("expected agent_execution_id = 'agent-456', got %s", receivedData.AgentExecutionID) } } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 92e353b001..3b5784233d 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -125,7 +125,7 @@ type TaskRepositoryDTO struct { type TaskSessionDTO struct { ID string `json:"id"` TaskID string `json:"task_id"` - AgentInstanceID string `json:"agent_instance_id,omitempty"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` ContainerID string `json:"container_id,omitempty"` AgentProfileID string `json:"agent_profile_id,omitempty"` ExecutorID string `json:"executor_id,omitempty"` @@ -400,7 +400,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { result := TaskSessionDTO{ ID: session.ID, TaskID: session.TaskID, - AgentInstanceID: session.AgentInstanceID, + AgentExecutionID: session.AgentExecutionID, ContainerID: session.ContainerID, AgentProfileID: session.AgentProfileID, ExecutorID: session.ExecutorID, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 791dfde452..cd25aa3bea 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -105,7 +105,7 @@ type Message struct { TaskSessionID string `json:"task_session_id"` TaskID string `json:"task_id,omitempty"` AuthorType MessageAuthorType `json:"author_type"` - AuthorID string `json:"author_id,omitempty"` // User ID or Agent Instance ID + AuthorID string `json:"author_id,omitempty"` // User ID or Agent Execution ID Content string `json:"content"` Type MessageType `json:"type,omitempty"` // Defaults to "message" Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -173,8 +173,8 @@ type TaskSessionWorktree struct { type TaskSession struct { ID string `json:"id"` TaskID string `json:"task_id"` - AgentInstanceID string `json:"agent_instance_id"` // Docker container/agent instance - ContainerID string `json:"container_id"` // Docker container ID for cleanup + AgentExecutionID string `json:"agent_execution_id"` // Docker container/agent execution + ContainerID string `json:"container_id"` // Docker container ID for cleanup AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used ExecutorID string `json:"executor_id"` EnvironmentID string `json:"environment_id"` @@ -198,10 +198,10 @@ type TaskSession struct { // TODO: Add v1.TaskSession type to pkg/api/v1/ func (s *TaskSession) ToAPI() map[string]interface{} { result := map[string]interface{}{ - "id": s.ID, - "task_id": s.TaskID, - "agent_instance_id": s.AgentInstanceID, - "container_id": s.ContainerID, + "id": s.ID, + "task_id": s.TaskID, + "agent_execution_id": s.AgentExecutionID, + "container_id": s.ContainerID, "agent_profile_id": s.AgentProfileID, "executor_id": s.ExecutorID, "environment_id": s.EnvironmentID, diff --git a/apps/backend/internal/task/repository/memory.go b/apps/backend/internal/task/repository/memory.go deleted file mode 100644 index 3db1eb0399..0000000000 --- a/apps/backend/internal/task/repository/memory.go +++ /dev/null @@ -1,1355 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "sort" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/kandev/kandev/internal/task/models" - v1 "github.com/kandev/kandev/pkg/api/v1" -) - -// MemoryRepository provides in-memory task storage operations -type MemoryRepository struct { - workspaces map[string]*models.Workspace - tasks map[string]*models.Task - boards map[string]*models.Board - columns map[string]*models.Column - messages map[string]*models.Message - repositories map[string]*models.Repository - repoScripts map[string]*models.RepositoryScript - taskRepositories map[string]*models.TaskRepository - taskSessions map[string]*models.TaskSession - taskSessionWorktrees map[string]*models.TaskSessionWorktree - executors map[string]*models.Executor - environments map[string]*models.Environment - taskBoards map[string]map[string]struct{} - taskPlacements map[string]map[string]*taskPlacement - mu sync.RWMutex -} - -// Ensure MemoryRepository implements Repository interface -var _ Repository = (*MemoryRepository)(nil) - -// NewMemoryRepository creates a new in-memory task repository -func NewMemoryRepository() *MemoryRepository { - repo := &MemoryRepository{ - workspaces: make(map[string]*models.Workspace), - tasks: make(map[string]*models.Task), - boards: make(map[string]*models.Board), - columns: make(map[string]*models.Column), - messages: make(map[string]*models.Message), - repositories: make(map[string]*models.Repository), - repoScripts: make(map[string]*models.RepositoryScript), - taskRepositories: make(map[string]*models.TaskRepository), - taskSessions: make(map[string]*models.TaskSession), - taskSessionWorktrees: make(map[string]*models.TaskSessionWorktree), - executors: make(map[string]*models.Executor), - environments: make(map[string]*models.Environment), - taskBoards: make(map[string]map[string]struct{}), - taskPlacements: make(map[string]map[string]*taskPlacement), - } - repo.seedDefaults() - return repo -} - -func (r *MemoryRepository) seedDefaults() { - now := time.Now().UTC() - r.executors[models.ExecutorIDLocalPC] = &models.Executor{ - ID: models.ExecutorIDLocalPC, - Name: "Local PC", - Type: models.ExecutorTypeLocalPC, - Status: models.ExecutorStatusActive, - IsSystem: true, - Config: map[string]string{}, - CreatedAt: now, - UpdatedAt: now, - } - r.executors[models.ExecutorIDLocalDocker] = &models.Executor{ - ID: models.ExecutorIDLocalDocker, - Name: "Local Docker", - Type: models.ExecutorTypeLocalDocker, - Status: models.ExecutorStatusActive, - IsSystem: false, - Config: map[string]string{"docker_host": "unix:///var/run/docker.sock"}, - CreatedAt: now, - UpdatedAt: now, - } - r.executors[models.ExecutorIDRemoteDocker] = &models.Executor{ - ID: models.ExecutorIDRemoteDocker, - Name: "Remote Docker", - Type: models.ExecutorTypeRemoteDocker, - Status: models.ExecutorStatusDisabled, - IsSystem: false, - Config: map[string]string{}, - CreatedAt: now, - UpdatedAt: now, - } - r.environments[models.EnvironmentIDLocal] = &models.Environment{ - ID: models.EnvironmentIDLocal, - Name: "Local", - Kind: models.EnvironmentKindLocalPC, - WorktreeRoot: "~/kandev", - CreatedAt: now, - UpdatedAt: now, - } -} - -type taskPlacement struct { - boardID string - columnID string - position int -} - -// Close is a no-op for in-memory repository -func (r *MemoryRepository) Close() error { - return nil -} - -// Workspace operations - -// CreateWorkspace creates a new workspace -func (r *MemoryRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error { - r.mu.Lock() - defer r.mu.Unlock() - - if workspace.ID == "" { - workspace.ID = uuid.New().String() - } - now := time.Now().UTC() - workspace.CreatedAt = now - workspace.UpdatedAt = now - - r.workspaces[workspace.ID] = workspace - return nil -} - -// GetWorkspace retrieves a workspace by ID -func (r *MemoryRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - workspace, ok := r.workspaces[id] - if !ok { - return nil, fmt.Errorf("workspace not found: %s", id) - } - return workspace, nil -} - -// UpdateWorkspace updates an existing workspace -func (r *MemoryRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.workspaces[workspace.ID]; !ok { - return fmt.Errorf("workspace not found: %s", workspace.ID) - } - workspace.UpdatedAt = time.Now().UTC() - r.workspaces[workspace.ID] = workspace - return nil -} - -// DeleteWorkspace deletes a workspace by ID -func (r *MemoryRepository) DeleteWorkspace(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.workspaces[id]; !ok { - return fmt.Errorf("workspace not found: %s", id) - } - delete(r.workspaces, id) - return nil -} - -// ListWorkspaces returns all workspaces -func (r *MemoryRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*models.Workspace, 0, len(r.workspaces)) - for _, workspace := range r.workspaces { - result = append(result, workspace) - } - return result, nil -} - -// Task operations - -// CreateTask creates a new task -func (r *MemoryRepository) CreateTask(ctx context.Context, task *models.Task) error { - r.mu.Lock() - defer r.mu.Unlock() - - if task.ID == "" { - task.ID = uuid.New().String() - } - now := time.Now().UTC() - task.CreatedAt = now - task.UpdatedAt = now - - r.tasks[task.ID] = task - if task.BoardID != "" { - r.addTaskPlacement(task.ID, task.BoardID, task.ColumnID, task.Position) - } - return nil -} - -// GetTask retrieves a task by ID -func (r *MemoryRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - task, ok := r.tasks[id] - if !ok { - return nil, fmt.Errorf("task not found: %s", id) - } - return task, nil -} - -// UpdateTask updates an existing task -func (r *MemoryRepository) UpdateTask(ctx context.Context, task *models.Task) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.tasks[task.ID]; !ok { - return fmt.Errorf("task not found: %s", task.ID) - } - task.UpdatedAt = time.Now().UTC() - r.tasks[task.ID] = task - if task.BoardID != "" { - r.addTaskPlacement(task.ID, task.BoardID, task.ColumnID, task.Position) - } - return nil -} - -// DeleteTask deletes a task by ID -func (r *MemoryRepository) DeleteTask(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.tasks[id]; !ok { - return fmt.Errorf("task not found: %s", id) - } - delete(r.tasks, id) - delete(r.taskBoards, id) - delete(r.taskPlacements, id) - return nil -} - -// ListTasks returns all tasks for a board -func (r *MemoryRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - var result []*models.Task - for _, task := range r.tasks { - if task.BoardID == boardID { - result = append(result, task) - } - } - return result, nil -} - -// ListTasksByColumn returns all tasks in a column -func (r *MemoryRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - var result []*models.Task - for _, task := range r.tasks { - if task.ColumnID == columnID { - result = append(result, task) - } - } - return result, nil -} - -// UpdateTaskState updates the state of a task -func (r *MemoryRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { - r.mu.Lock() - defer r.mu.Unlock() - - task, ok := r.tasks[id] - if !ok { - return fmt.Errorf("task not found: %s", id) - } - task.State = state - task.UpdatedAt = time.Now().UTC() - return nil -} - -// AddTaskToBoard adds a task to a board with placement -func (r *MemoryRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error { - r.mu.Lock() - defer r.mu.Unlock() - - task, ok := r.tasks[taskID] - if !ok { - return fmt.Errorf("task not found: %s", taskID) - } - task.BoardID = boardID - task.ColumnID = columnID - task.Position = position - task.UpdatedAt = time.Now().UTC() - r.tasks[taskID] = task - r.addTaskPlacement(taskID, boardID, columnID, position) - return nil -} - -// RemoveTaskFromBoard removes a task from a board -func (r *MemoryRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { - r.mu.Lock() - defer r.mu.Unlock() - - task, ok := r.tasks[taskID] - if !ok { - return fmt.Errorf("task not found: %s", taskID) - } - if task.BoardID == boardID { - task.BoardID = "" - task.ColumnID = "" - task.Position = 0 - task.UpdatedAt = time.Now().UTC() - r.tasks[taskID] = task - } - if placements, ok := r.taskPlacements[taskID]; ok { - delete(placements, boardID) - } - if boards, ok := r.taskBoards[taskID]; ok { - delete(boards, boardID) - } - return nil -} - -// TaskRepository operations - -// CreateTaskRepository creates a new task-repository association -func (r *MemoryRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { - r.mu.Lock() - defer r.mu.Unlock() - - if taskRepo.ID == "" { - taskRepo.ID = uuid.New().String() - } - now := time.Now().UTC() - taskRepo.CreatedAt = now - taskRepo.UpdatedAt = now - - r.taskRepositories[taskRepo.ID] = taskRepo - return nil -} - -// GetTaskRepository retrieves a task repository by ID -func (r *MemoryRepository) GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - taskRepo, ok := r.taskRepositories[id] - if !ok { - return nil, fmt.Errorf("task repository not found: %s", id) - } - return taskRepo, nil -} - -// ListTaskRepositories retrieves all repositories for a task, ordered by position -func (r *MemoryRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - var result []*models.TaskRepository - for _, taskRepo := range r.taskRepositories { - if taskRepo.TaskID == taskID { - result = append(result, taskRepo) - } - } - - // Sort by position then created_at - sort.Slice(result, func(i, j int) bool { - if result[i].Position != result[j].Position { - return result[i].Position < result[j].Position - } - return result[i].CreatedAt.Before(result[j].CreatedAt) - }) - - return result, nil -} - -// UpdateTaskRepository updates an existing task repository -func (r *MemoryRepository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.taskRepositories[taskRepo.ID]; !ok { - return fmt.Errorf("task repository not found: %s", taskRepo.ID) - } - - taskRepo.UpdatedAt = time.Now().UTC() - r.taskRepositories[taskRepo.ID] = taskRepo - return nil -} - -// DeleteTaskRepository deletes a task repository by ID -func (r *MemoryRepository) DeleteTaskRepository(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.taskRepositories[id]; !ok { - return fmt.Errorf("task repository not found: %s", id) - } - - delete(r.taskRepositories, id) - return nil -} - -// DeleteTaskRepositoriesByTask deletes all repositories for a task -func (r *MemoryRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error { - r.mu.Lock() - defer r.mu.Unlock() - - for id, taskRepo := range r.taskRepositories { - if taskRepo.TaskID == taskID { - delete(r.taskRepositories, id) - } - } - return nil -} - -// GetPrimaryTaskRepository retrieves the primary repository for a task (position=0 or first by created_at) -func (r *MemoryRepository) GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) { - repos, err := r.ListTaskRepositories(ctx, taskID) - if err != nil { - return nil, err - } - if len(repos) == 0 { - return nil, nil // No repositories for this task - } - return repos[0], nil -} - -// Board operations - -// CreateBoard creates a new board -func (r *MemoryRepository) CreateBoard(ctx context.Context, board *models.Board) error { - r.mu.Lock() - defer r.mu.Unlock() - - if board.ID == "" { - board.ID = uuid.New().String() - } - now := time.Now().UTC() - board.CreatedAt = now - board.UpdatedAt = now - - r.boards[board.ID] = board - return nil -} - -// GetBoard retrieves a board by ID -func (r *MemoryRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - board, ok := r.boards[id] - if !ok { - return nil, fmt.Errorf("board not found: %s", id) - } - return board, nil -} - -// UpdateBoard updates an existing board -func (r *MemoryRepository) UpdateBoard(ctx context.Context, board *models.Board) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.boards[board.ID]; !ok { - return fmt.Errorf("board not found: %s", board.ID) - } - board.UpdatedAt = time.Now().UTC() - r.boards[board.ID] = board - return nil -} - -// DeleteBoard deletes a board by ID -func (r *MemoryRepository) DeleteBoard(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.boards[id]; !ok { - return fmt.Errorf("board not found: %s", id) - } - delete(r.boards, id) - return nil -} - -// ListBoards returns all boards -func (r *MemoryRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*models.Board, 0, len(r.boards)) - for _, board := range r.boards { - if workspaceID != "" && board.WorkspaceID != workspaceID { - continue - } - result = append(result, board) - } - return result, nil -} - -// Column operations - -// CreateColumn creates a new column -func (r *MemoryRepository) CreateColumn(ctx context.Context, column *models.Column) error { - r.mu.Lock() - defer r.mu.Unlock() - - if column.ID == "" { - column.ID = uuid.New().String() - } - now := time.Now().UTC() - column.CreatedAt = now - column.UpdatedAt = now - - r.columns[column.ID] = column - return nil -} - -// GetColumn retrieves a column by ID -func (r *MemoryRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - column, ok := r.columns[id] - if !ok { - return nil, fmt.Errorf("column not found: %s", id) - } - return column, nil -} - -// GetColumnByState retrieves a column by board ID and state -func (r *MemoryRepository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, column := range r.columns { - if column.BoardID == boardID && column.State == state { - return column, nil - } - } - return nil, fmt.Errorf("column not found for board %s with state %s", boardID, state) -} - -// UpdateColumn updates an existing column -func (r *MemoryRepository) UpdateColumn(ctx context.Context, column *models.Column) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.columns[column.ID]; !ok { - return fmt.Errorf("column not found: %s", column.ID) - } - column.UpdatedAt = time.Now().UTC() - r.columns[column.ID] = column - return nil -} - -// DeleteColumn deletes a column by ID -func (r *MemoryRepository) DeleteColumn(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.columns[id]; !ok { - return fmt.Errorf("column not found: %s", id) - } - delete(r.columns, id) - return nil -} - -// ListColumns returns all columns for a board -func (r *MemoryRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - var result []*models.Column - for _, column := range r.columns { - if column.BoardID == boardID { - result = append(result, column) - } - } - return result, nil -} - -// Repository operations - -func (r *MemoryRepository) CreateRepository(ctx context.Context, repository *models.Repository) error { - r.mu.Lock() - defer r.mu.Unlock() - - if repository.ID == "" { - repository.ID = uuid.New().String() - } - now := time.Now().UTC() - repository.CreatedAt = now - repository.UpdatedAt = now - r.repositories[repository.ID] = repository - return nil -} - -func (r *MemoryRepository) GetRepository(ctx context.Context, id string) (*models.Repository, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - repository, ok := r.repositories[id] - if !ok || repository.DeletedAt != nil { - return nil, fmt.Errorf("repository not found: %s", id) - } - return repository, nil -} - -func (r *MemoryRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error { - r.mu.Lock() - defer r.mu.Unlock() - - if existing, ok := r.repositories[repository.ID]; !ok || existing.DeletedAt != nil { - return fmt.Errorf("repository not found: %s", repository.ID) - } - repository.UpdatedAt = time.Now().UTC() - r.repositories[repository.ID] = repository - return nil -} - -func (r *MemoryRepository) DeleteRepository(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - repository, ok := r.repositories[id] - if !ok || repository.DeletedAt != nil { - return fmt.Errorf("repository not found: %s", id) - } - now := time.Now().UTC() - repository.DeletedAt = &now - repository.UpdatedAt = now - r.repositories[id] = repository - return nil -} - -func (r *MemoryRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*models.Repository, 0) - for _, repository := range r.repositories { - if repository.WorkspaceID == workspaceID && repository.DeletedAt == nil { - result = append(result, repository) - } - } - return result, nil -} - -// Repository script operations - -func (r *MemoryRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { - r.mu.Lock() - defer r.mu.Unlock() - - if script.ID == "" { - script.ID = uuid.New().String() - } - now := time.Now().UTC() - script.CreatedAt = now - script.UpdatedAt = now - r.repoScripts[script.ID] = script - return nil -} - -func (r *MemoryRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - script, ok := r.repoScripts[id] - if !ok { - return nil, fmt.Errorf("repository script not found: %s", id) - } - return script, nil -} - -func (r *MemoryRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.repoScripts[script.ID]; !ok { - return fmt.Errorf("repository script not found: %s", script.ID) - } - script.UpdatedAt = time.Now().UTC() - r.repoScripts[script.ID] = script - return nil -} - -func (r *MemoryRepository) DeleteRepositoryScript(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.repoScripts[id]; !ok { - return fmt.Errorf("repository script not found: %s", id) - } - delete(r.repoScripts, id) - return nil -} - -func (r *MemoryRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*models.RepositoryScript, 0) - for _, script := range r.repoScripts { - if script.RepositoryID == repositoryID { - result = append(result, script) - } - } - return result, nil -} - -func (r *MemoryRepository) addTaskPlacement(taskID, boardID, columnID string, position int) { - if _, ok := r.taskBoards[taskID]; !ok { - r.taskBoards[taskID] = make(map[string]struct{}) - } - r.taskBoards[taskID][boardID] = struct{}{} - - if _, ok := r.taskPlacements[taskID]; !ok { - r.taskPlacements[taskID] = make(map[string]*taskPlacement) - } - r.taskPlacements[taskID][boardID] = &taskPlacement{ - boardID: boardID, - columnID: columnID, - position: position, - } -} - -func isSessionActive(status models.TaskSessionState) bool { - return status == models.TaskSessionStateCreated || - status == models.TaskSessionStateRunning || - status == models.TaskSessionStateWaitingForInput || - status == models.TaskSessionStateStarting -} - -// Message operations - -// CreateMessage creates a new message -func (r *MemoryRepository) CreateMessage(ctx context.Context, message *models.Message) error { - r.mu.Lock() - defer r.mu.Unlock() - - if message.ID == "" { - message.ID = uuid.New().String() - } - if message.CreatedAt.IsZero() { - message.CreatedAt = time.Now().UTC() - } - if message.AuthorType == "" { - message.AuthorType = models.MessageAuthorUser - } - if message.Type == "" { - message.Type = models.MessageTypeMessage - } - - r.messages[message.ID] = message - return nil -} - -// GetMessage retrieves a message by ID -func (r *MemoryRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - message, ok := r.messages[id] - if !ok { - return nil, fmt.Errorf("message not found: %s", id) - } - return message, nil -} - -// GetMessageByToolCallID retrieves a message by session ID and tool_call_id in metadata. -func (r *MemoryRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, message := range r.messages { - if message.TaskSessionID != sessionID || message.Metadata == nil { - continue - } - if value, ok := message.Metadata["tool_call_id"]; ok { - if toolCallIDValue, ok := value.(string); ok && toolCallIDValue == toolCallID { - return message, nil - } - } - } - return nil, fmt.Errorf("message not found: %s", toolCallID) -} - -// UpdateMessage updates an existing message. -func (r *MemoryRepository) UpdateMessage(ctx context.Context, message *models.Message) error { - r.mu.Lock() - defer r.mu.Unlock() - - existing, ok := r.messages[message.ID] - if !ok { - return fmt.Errorf("message not found: %s", message.ID) - } - message.CreatedAt = existing.CreatedAt - r.messages[message.ID] = message - return nil -} - -// ListMessages returns all messages for a session ordered by creation time. -func (r *MemoryRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - var result []*models.Message - for _, message := range r.messages { - if message.TaskSessionID == sessionID { - result = append(result, message) - } - } - sort.Slice(result, func(i, j int) bool { - if result[i].CreatedAt.Equal(result[j].CreatedAt) { - return result[i].ID < result[j].ID - } - return result[i].CreatedAt.Before(result[j].CreatedAt) - }) - return result, nil -} - -// ListMessagesPaginated returns messages for a session ordered by creation time with pagination. -func (r *MemoryRepository) ListMessagesPaginated(ctx context.Context, sessionID string, opts ListMessagesOptions) ([]*models.Message, bool, error) { - limit := opts.Limit - if limit < 0 { - limit = 0 - } - - r.mu.RLock() - defer r.mu.RUnlock() - - var cursor *models.Message - if opts.Before != "" { - message, ok := r.messages[opts.Before] - if !ok { - return nil, false, fmt.Errorf("message not found: %s", opts.Before) - } - if message.TaskSessionID != sessionID { - return nil, false, fmt.Errorf("message cursor not found: %s", opts.Before) - } - cursor = message - } - if opts.After != "" { - message, ok := r.messages[opts.After] - if !ok { - return nil, false, fmt.Errorf("message not found: %s", opts.After) - } - if message.TaskSessionID != sessionID { - return nil, false, fmt.Errorf("message cursor not found: %s", opts.After) - } - cursor = message - } - - var result []*models.Message - for _, message := range r.messages { - if message.TaskSessionID == sessionID { - result = append(result, message) - } - } - sort.Slice(result, func(i, j int) bool { - if result[i].CreatedAt.Equal(result[j].CreatedAt) { - return result[i].ID < result[j].ID - } - return result[i].CreatedAt.Before(result[j].CreatedAt) - }) - - if cursor != nil { - filtered := result[:0] - for _, message := range result { - if opts.Before != "" { - if message.CreatedAt.Before(cursor.CreatedAt) || (message.CreatedAt.Equal(cursor.CreatedAt) && message.ID < cursor.ID) { - filtered = append(filtered, message) - } - } else if opts.After != "" { - if message.CreatedAt.After(cursor.CreatedAt) || (message.CreatedAt.Equal(cursor.CreatedAt) && message.ID > cursor.ID) { - filtered = append(filtered, message) - } - } - } - result = filtered - } - - if strings.EqualFold(opts.Sort, "desc") { - for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { - result[i], result[j] = result[j], result[i] - } - } - - hasMore := false - if limit > 0 && len(result) > limit { - hasMore = true - result = result[:limit] - } - return result, hasMore, nil -} - -// DeleteMessage deletes a message by ID -func (r *MemoryRepository) DeleteMessage(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.messages[id]; !ok { - return fmt.Errorf("message not found: %s", id) - } - delete(r.messages, id) - return nil -} - -// TaskSession operations - -// CreateTaskSession creates a new agent session -func (r *MemoryRepository) CreateTaskSession(ctx context.Context, session *models.TaskSession) error { - r.mu.Lock() - defer r.mu.Unlock() - - if session.ID == "" { - session.ID = uuid.New().String() - } - now := time.Now().UTC() - if session.StartedAt.IsZero() { - session.StartedAt = now - } - if session.State == "" { - session.State = models.TaskSessionStateCreated - } - session.UpdatedAt = now - - r.taskSessions[session.ID] = session - return nil -} - -// GetTaskSession retrieves an agent session by ID -func (r *MemoryRepository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { - r.mu.RLock() - - session, ok := r.taskSessions[id] - if !ok { - r.mu.RUnlock() - return nil, fmt.Errorf("agent session not found: %s", id) - } - sessionCopy := *session - r.mu.RUnlock() - - worktrees, err := r.ListTaskSessionWorktrees(ctx, sessionCopy.ID) - if err != nil { - return nil, err - } - sessionCopy.Worktrees = worktrees - return &sessionCopy, nil -} - -// GetTaskSessionByTaskID retrieves the most recent agent session for a task -func (r *MemoryRepository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { - r.mu.RLock() - - var latest *models.TaskSession - for _, session := range r.taskSessions { - if session.TaskID == taskID { - if latest == nil || session.StartedAt.After(latest.StartedAt) { - latest = session - } - } - } - if latest == nil { - r.mu.RUnlock() - return nil, fmt.Errorf("no agent session found for task: %s", taskID) - } - sessionCopy := *latest - r.mu.RUnlock() - - worktrees, err := r.ListTaskSessionWorktrees(ctx, sessionCopy.ID) - if err != nil { - return nil, err - } - sessionCopy.Worktrees = worktrees - return &sessionCopy, nil -} - -// GetActiveTaskSessionByTaskID retrieves the active agent session for a task -func (r *MemoryRepository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { - r.mu.RLock() - - for _, session := range r.taskSessions { - if session.TaskID == taskID && (session.State == models.TaskSessionStateCreated || session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateWaitingForInput) { - sessionCopy := *session - r.mu.RUnlock() - - worktrees, err := r.ListTaskSessionWorktrees(ctx, sessionCopy.ID) - if err != nil { - return nil, err - } - sessionCopy.Worktrees = worktrees - return &sessionCopy, nil - } - } - r.mu.RUnlock() - return nil, fmt.Errorf("no active agent session found for task: %s", taskID) -} - -// UpdateTaskSession updates an existing agent session -func (r *MemoryRepository) UpdateTaskSession(ctx context.Context, session *models.TaskSession) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.taskSessions[session.ID]; !ok { - return fmt.Errorf("agent session not found: %s", session.ID) - } - session.UpdatedAt = time.Now().UTC() - r.taskSessions[session.ID] = session - return nil -} - -// UpdateTaskSessionState updates the status of an agent session -func (r *MemoryRepository) UpdateTaskSessionState(ctx context.Context, id string, status models.TaskSessionState, errorMessage string) error { - r.mu.Lock() - defer r.mu.Unlock() - - session, ok := r.taskSessions[id] - if !ok { - return fmt.Errorf("agent session not found: %s", id) - } - session.State = status - session.ErrorMessage = errorMessage - session.UpdatedAt = time.Now().UTC() - if status == models.TaskSessionStateCompleted || status == models.TaskSessionStateFailed || status == models.TaskSessionStateCancelled { - now := time.Now().UTC() - session.CompletedAt = &now - } - return nil -} - -// ListTaskSessions returns all agent sessions for a task -func (r *MemoryRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { - r.mu.RLock() - - var result []*models.TaskSession - for _, session := range r.taskSessions { - if session.TaskID == taskID { - sessionCopy := *session - result = append(result, &sessionCopy) - } - } - r.mu.RUnlock() - - for _, session := range result { - worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) - if err != nil { - return nil, err - } - session.Worktrees = worktrees - } - return result, nil -} - -// ListActiveTaskSessions returns all active agent sessions -func (r *MemoryRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { - r.mu.RLock() - - var result []*models.TaskSession - for _, session := range r.taskSessions { - if session.State == models.TaskSessionStateCreated || session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateWaitingForInput { - sessionCopy := *session - result = append(result, &sessionCopy) - } - } - r.mu.RUnlock() - - for _, session := range result { - worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID) - if err != nil { - return nil, err - } - session.Worktrees = worktrees - } - return result, nil -} - -func (r *MemoryRepository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, session := range r.taskSessions { - if session.AgentProfileID == agentProfileID && isSessionActive(session.State) { - return true, nil - } - } - return false, nil -} - -func (r *MemoryRepository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, session := range r.taskSessions { - if session.ExecutorID == executorID && isSessionActive(session.State) { - return true, nil - } - } - return false, nil -} - -func (r *MemoryRepository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, session := range r.taskSessions { - if session.EnvironmentID == environmentID && isSessionActive(session.State) { - return true, nil - } - } - return false, nil -} - -func (r *MemoryRepository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - for _, session := range r.taskSessions { - if !isSessionActive(session.State) { - continue - } - // Check if any task repository matches - for _, taskRepo := range r.taskRepositories { - if taskRepo.TaskID == session.TaskID && taskRepo.RepositoryID == repositoryID { - return true, nil - } - } - } - return false, nil -} - -// DeleteTaskSession deletes an agent session by ID -func (r *MemoryRepository) DeleteTaskSession(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.taskSessions[id]; !ok { - return fmt.Errorf("agent session not found: %s", id) - } - delete(r.taskSessions, id) - for wtID, wt := range r.taskSessionWorktrees { - if wt.SessionID == id { - delete(r.taskSessionWorktrees, wtID) - } - } - return nil -} - -// Task Session Worktree operations - -func (r *MemoryRepository) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error { - r.mu.Lock() - defer r.mu.Unlock() - - for _, existing := range r.taskSessionWorktrees { - if existing.SessionID == sessionWorktree.SessionID && existing.WorktreeID == sessionWorktree.WorktreeID { - existing.RepositoryID = sessionWorktree.RepositoryID - existing.Position = sessionWorktree.Position - existing.WorktreePath = sessionWorktree.WorktreePath - existing.WorktreeBranch = sessionWorktree.WorktreeBranch - return nil - } - } - if sessionWorktree.ID == "" { - sessionWorktree.ID = uuid.New().String() - } - sessionWorktree.CreatedAt = time.Now().UTC() - - r.taskSessionWorktrees[sessionWorktree.ID] = sessionWorktree - return nil -} - -func (r *MemoryRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - var worktrees []*models.TaskSessionWorktree - for _, wt := range r.taskSessionWorktrees { - if wt.SessionID == sessionID { - worktrees = append(worktrees, wt) - } - } - - // Sort by position, then created_at - sort.Slice(worktrees, func(i, j int) bool { - if worktrees[i].Position != worktrees[j].Position { - return worktrees[i].Position < worktrees[j].Position - } - return worktrees[i].CreatedAt.Before(worktrees[j].CreatedAt) - }) - - return worktrees, nil -} - -func (r *MemoryRepository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.taskSessionWorktrees[id]; !ok { - return fmt.Errorf("task session worktree not found: %s", id) - } - delete(r.taskSessionWorktrees, id) - return nil -} - -func (r *MemoryRepository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { - r.mu.Lock() - defer r.mu.Unlock() - - for id, wt := range r.taskSessionWorktrees { - if wt.SessionID == sessionID { - delete(r.taskSessionWorktrees, id) - } - } - return nil -} - -// Executor operations - -func (r *MemoryRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { - r.mu.Lock() - defer r.mu.Unlock() - - if executor.ID == "" { - executor.ID = uuid.New().String() - } - now := time.Now().UTC() - executor.CreatedAt = now - executor.UpdatedAt = now - - r.executors[executor.ID] = executor - return nil -} - -func (r *MemoryRepository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - executor, ok := r.executors[id] - if !ok || executor.DeletedAt != nil { - return nil, fmt.Errorf("executor not found: %s", id) - } - return executor, nil -} - -func (r *MemoryRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error { - r.mu.Lock() - defer r.mu.Unlock() - - if existing, ok := r.executors[executor.ID]; !ok || existing.DeletedAt != nil { - return fmt.Errorf("executor not found: %s", executor.ID) - } - executor.UpdatedAt = time.Now().UTC() - r.executors[executor.ID] = executor - return nil -} - -func (r *MemoryRepository) DeleteExecutor(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - executor, ok := r.executors[id] - if !ok || executor.DeletedAt != nil { - return fmt.Errorf("executor not found: %s", id) - } - now := time.Now().UTC() - executor.DeletedAt = &now - executor.UpdatedAt = now - r.executors[id] = executor - return nil -} - -func (r *MemoryRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*models.Executor, 0, len(r.executors)) - for _, executor := range r.executors { - if executor.DeletedAt == nil { - result = append(result, executor) - } - } - return result, nil -} - -// Environment operations - -func (r *MemoryRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { - r.mu.Lock() - defer r.mu.Unlock() - - if environment.ID == "" { - environment.ID = uuid.New().String() - } - now := time.Now().UTC() - environment.CreatedAt = now - environment.UpdatedAt = now - r.environments[environment.ID] = environment - return nil -} - -func (r *MemoryRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - environment, ok := r.environments[id] - if !ok || environment.DeletedAt != nil { - return nil, fmt.Errorf("environment not found: %s", id) - } - return environment, nil -} - -func (r *MemoryRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error { - r.mu.Lock() - defer r.mu.Unlock() - - if existing, ok := r.environments[environment.ID]; !ok || existing.DeletedAt != nil { - return fmt.Errorf("environment not found: %s", environment.ID) - } - environment.UpdatedAt = time.Now().UTC() - r.environments[environment.ID] = environment - return nil -} - -func (r *MemoryRepository) DeleteEnvironment(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - environment, ok := r.environments[id] - if !ok || environment.DeletedAt != nil { - return fmt.Errorf("environment not found: %s", id) - } - now := time.Now().UTC() - environment.DeletedAt = &now - environment.UpdatedAt = now - r.environments[id] = environment - return nil -} - -func (r *MemoryRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*models.Environment, 0, len(r.environments)) - for _, environment := range r.environments { - if environment.DeletedAt == nil { - result = append(result, environment) - } - } - return result, nil -} diff --git a/apps/backend/internal/task/repository/memory_test.go b/apps/backend/internal/task/repository/memory_test.go deleted file mode 100644 index f12f1eb92a..0000000000 --- a/apps/backend/internal/task/repository/memory_test.go +++ /dev/null @@ -1,530 +0,0 @@ -package repository - -import ( - "context" - "testing" - "time" - - "github.com/kandev/kandev/internal/task/models" - v1 "github.com/kandev/kandev/pkg/api/v1" -) - -func TestNewMemoryRepository(t *testing.T) { - repo := NewMemoryRepository() - if repo == nil { - t.Fatal("expected non-nil repository") - } - if repo.tasks == nil { - t.Error("expected tasks map to be initialized") - } - if repo.workspaces == nil { - t.Error("expected workspaces map to be initialized") - } - if repo.boards == nil { - t.Error("expected boards map to be initialized") - } - if repo.columns == nil { - t.Error("expected columns map to be initialized") - } -} - -func TestMemoryRepository_Close(t *testing.T) { - repo := NewMemoryRepository() - err := repo.Close() - if err != nil { - t.Errorf("expected no error on close, got %v", err) - } -} - -// Board CRUD tests - -func TestMemoryRepository_BoardCRUD(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - workspace := &models.Workspace{ID: "ws-1", Name: "Workspace"} - if err := repo.CreateWorkspace(ctx, workspace); err != nil { - t.Fatalf("failed to create workspace: %v", err) - } - - // Create - board := &models.Board{WorkspaceID: workspace.ID, Name: "Test Board", Description: "A test board"} - if err := repo.CreateBoard(ctx, board); err != nil { - t.Fatalf("failed to create board: %v", err) - } - if board.ID == "" { - t.Error("expected board ID to be set") - } - if board.CreatedAt.IsZero() { - t.Error("expected CreatedAt to be set") - } - - // Get - retrieved, err := repo.GetBoard(ctx, board.ID) - if err != nil { - t.Fatalf("failed to get board: %v", err) - } - if retrieved.Name != "Test Board" { - t.Errorf("expected name 'Test Board', got %s", retrieved.Name) - } - - // Update - board.Name = "Updated Name" - if err := repo.UpdateBoard(ctx, board); err != nil { - t.Fatalf("failed to update board: %v", err) - } - retrieved, _ = repo.GetBoard(ctx, board.ID) - if retrieved.Name != "Updated Name" { - t.Errorf("expected name 'Updated Name', got %s", retrieved.Name) - } - - // Delete - if err := repo.DeleteBoard(ctx, board.ID); err != nil { - t.Fatalf("failed to delete board: %v", err) - } - _, err = repo.GetBoard(ctx, board.ID) - if err == nil { - t.Error("expected board to be deleted") - } -} - -func TestMemoryRepository_BoardNotFound(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - _, err := repo.GetBoard(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent board") - } - - err = repo.UpdateBoard(ctx, &models.Board{ID: "nonexistent", Name: "Test"}) - if err == nil { - t.Error("expected error for updating nonexistent board") - } - - err = repo.DeleteBoard(ctx, "nonexistent") - if err == nil { - t.Error("expected error for deleting nonexistent board") - } -} - -func TestMemoryRepository_ListBoards(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) - - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", WorkspaceID: "ws-1", Name: "Board 1"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", WorkspaceID: "ws-1", Name: "Board 2"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-3", WorkspaceID: "ws-2", Name: "Board 3"}) - - boards, err := repo.ListBoards(ctx, "ws-1") - if err != nil { - t.Fatalf("failed to list boards: %v", err) - } - if len(boards) != 2 { - t.Errorf("expected 2 boards, got %d", len(boards)) - } -} - -// Column CRUD tests - -func TestMemoryRepository_ColumnCRUD(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - // Create - column := &models.Column{BoardID: "board-123", Name: "To Do", Position: 0, State: v1.TaskStateTODO} - if err := repo.CreateColumn(ctx, column); err != nil { - t.Fatalf("failed to create column: %v", err) - } - if column.ID == "" { - t.Error("expected column ID to be set") - } - - // Get - retrieved, err := repo.GetColumn(ctx, column.ID) - if err != nil { - t.Fatalf("failed to get column: %v", err) - } - if retrieved.Name != "To Do" { - t.Errorf("expected name 'To Do', got %s", retrieved.Name) - } - - // Update - column.Name = "Done" - column.State = v1.TaskStateCompleted - if err := repo.UpdateColumn(ctx, column); err != nil { - t.Fatalf("failed to update column: %v", err) - } - retrieved, _ = repo.GetColumn(ctx, column.ID) - if retrieved.Name != "Done" { - t.Errorf("expected name 'Done', got %s", retrieved.Name) - } - - // Delete - if err := repo.DeleteColumn(ctx, column.ID); err != nil { - t.Fatalf("failed to delete column: %v", err) - } - _, err = repo.GetColumn(ctx, column.ID) - if err == nil { - t.Error("expected column to be deleted") - } -} - -func TestMemoryRepository_ColumnNotFound(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - _, err := repo.GetColumn(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent column") - } - - err = repo.UpdateColumn(ctx, &models.Column{ID: "nonexistent", Name: "Test"}) - if err == nil { - t.Error("expected error for updating nonexistent column") - } - - err = repo.DeleteColumn(ctx, "nonexistent") - if err == nil { - t.Error("expected error for deleting nonexistent column") - } -} - -func TestMemoryRepository_ListColumns(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - _ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"}) - _ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "In Progress"}) - _ = repo.CreateColumn(ctx, &models.Column{ID: "col-3", BoardID: "board-456", Name: "Done"}) - - columns, err := repo.ListColumns(ctx, "board-123") - if err != nil { - t.Fatalf("failed to list columns: %v", err) - } - if len(columns) != 2 { - t.Errorf("expected 2 columns for board-123, got %d", len(columns)) - } -} - -// Task CRUD tests - -func TestMemoryRepository_TaskCRUD(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - // Create - task := &models.Task{ - WorkspaceID: "ws-1", - BoardID: "board-123", - ColumnID: "col-123", - Title: "Test Task", - Description: "A test task", - State: v1.TaskStateTODO, - Priority: 5, - } - if err := repo.CreateTask(ctx, task); err != nil { - t.Fatalf("failed to create task: %v", err) - } - if task.ID == "" { - t.Error("expected task ID to be set") - } - if task.CreatedAt.IsZero() { - t.Error("expected CreatedAt to be set") - } - - // Get - retrieved, err := repo.GetTask(ctx, task.ID) - if err != nil { - t.Fatalf("failed to get task: %v", err) - } - if retrieved.Title != "Test Task" { - t.Errorf("expected title 'Test Task', got %s", retrieved.Title) - } - - // Update - task.Title = "Updated Task" - task.Description = "Updated description" - if err := repo.UpdateTask(ctx, task); err != nil { - t.Fatalf("failed to update task: %v", err) - } - retrieved, _ = repo.GetTask(ctx, task.ID) - if retrieved.Title != "Updated Task" { - t.Errorf("expected title 'Updated Task', got %s", retrieved.Title) - } - - // Delete - if err := repo.DeleteTask(ctx, task.ID); err != nil { - t.Fatalf("failed to delete task: %v", err) - } - _, err = repo.GetTask(ctx, task.ID) - if err == nil { - t.Error("expected task to be deleted") - } -} - -func TestMemoryRepository_TaskNotFound(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - _, err := repo.GetTask(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent task") - } - - err = repo.UpdateTask(ctx, &models.Task{ID: "nonexistent", Title: "Test"}) - if err == nil { - t.Error("expected error for updating nonexistent task") - } - - err = repo.DeleteTask(ctx, "nonexistent") - if err == nil { - t.Error("expected error for deleting nonexistent task") - } -} - -func TestMemoryRepository_UpdateTaskState(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO} - _ = repo.CreateTask(ctx, task) - - err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress) - if err != nil { - t.Fatalf("failed to update task state: %v", err) - } - - retrieved, _ := repo.GetTask(ctx, "task-123") - if retrieved.State != v1.TaskStateInProgress { - t.Errorf("expected state IN_PROGRESS, got %s", retrieved.State) - } -} - -func TestMemoryRepository_UpdateTaskStateNotFound(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - err := repo.UpdateTaskState(ctx, "nonexistent", v1.TaskStateInProgress) - if err == nil { - t.Error("expected error for nonexistent task") - } -} - -func TestMemoryRepository_ListTasks(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-456", ColumnID: "col-2", Title: "Task 3"}) - - tasks, err := repo.ListTasks(ctx, "board-123") - if err != nil { - t.Fatalf("failed to list tasks: %v", err) - } - if len(tasks) != 2 { - t.Errorf("expected 2 tasks for board-123, got %d", len(tasks)) - } -} - -func TestMemoryRepository_ListTasksByColumn(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"}) - - tasks, err := repo.ListTasksByColumn(ctx, "col-1") - if err != nil { - t.Fatalf("failed to list tasks by column: %v", err) - } - if len(tasks) != 2 { - t.Errorf("expected 2 tasks for col-1, got %d", len(tasks)) - } -} - -// Message CRUD tests - -func setupMemoryTestSession(ctx context.Context, repo *MemoryRepository, taskID string) string { - session := &models.TaskSession{ - ID: "session-123", - TaskID: taskID, - AgentProfileID: "profile-123", - State: models.TaskSessionStateStarting, - } - _ = repo.CreateTaskSession(ctx, session) - return session.ID -} - -func TestMemoryRepository_MessageCRUD(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - // Create a task first - task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - sessionID := setupMemoryTestSession(ctx, repo, task.ID) - - // Create comment - comment := &models.Message{ - TaskSessionID: sessionID, - TaskID: "task-123", - AuthorType: models.MessageAuthorUser, - AuthorID: "user-123", - Content: "This is a test comment", - RequestsInput: false, - } - if err := repo.CreateMessage(ctx, comment); err != nil { - t.Fatalf("failed to create comment: %v", err) - } - if comment.ID == "" { - t.Error("expected comment ID to be set") - } - if comment.CreatedAt.IsZero() { - t.Error("expected CreatedAt to be set") - } - - // Get comment - retrieved, err := repo.GetMessage(ctx, comment.ID) - if err != nil { - t.Fatalf("failed to get comment: %v", err) - } - if retrieved.Content != "This is a test comment" { - t.Errorf("expected content 'This is a test comment', got %s", retrieved.Content) - } - if retrieved.AuthorType != models.MessageAuthorUser { - t.Errorf("expected author type 'user', got %s", retrieved.AuthorType) - } - - // Delete comment - if err := repo.DeleteMessage(ctx, comment.ID); err != nil { - t.Fatalf("failed to delete comment: %v", err) - } - _, err = repo.GetMessage(ctx, comment.ID) - if err == nil { - t.Error("expected comment to be deleted") - } -} - -func TestMemoryRepository_MessageNotFound(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - _, err := repo.GetMessage(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent comment") - } - - err = repo.DeleteMessage(ctx, "nonexistent") - if err == nil { - t.Error("expected error for deleting nonexistent comment") - } -} - -func TestMemoryRepository_ListMessages(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - // Create a task - task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - sessionID := setupMemoryTestSession(ctx, repo, task.ID) - - // Create multiple comments - _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-1", TaskSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorUser, Content: "Comment 1"}) - _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-2", TaskSessionID: sessionID, TaskID: "task-123", AuthorType: models.MessageAuthorAgent, Content: "Comment 2"}) - _ = repo.CreateMessage(ctx, &models.Message{ID: "comment-3", TaskSessionID: "session-456", TaskID: "task-456", AuthorType: models.MessageAuthorUser, Content: "Comment 3"}) - - comments, err := repo.ListMessages(ctx, sessionID) - if err != nil { - t.Fatalf("failed to list comments: %v", err) - } - if len(comments) != 2 { - t.Errorf("expected 2 comments for task-123, got %d", len(comments)) - } -} - -func TestMemoryRepository_ListMessagesPagination(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - sessionID := setupMemoryTestSession(ctx, repo, task.ID) - - baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - _ = repo.CreateMessage(ctx, &models.Message{ - ID: "comment-1", - TaskSessionID: sessionID, - TaskID: "task-123", - AuthorType: models.MessageAuthorUser, - Content: "Comment 1", - CreatedAt: baseTime.Add(-2 * time.Minute), - }) - _ = repo.CreateMessage(ctx, &models.Message{ - ID: "comment-2", - TaskSessionID: sessionID, - TaskID: "task-123", - AuthorType: models.MessageAuthorUser, - Content: "Comment 2", - CreatedAt: baseTime.Add(-1 * time.Minute), - }) - _ = repo.CreateMessage(ctx, &models.Message{ - ID: "comment-3", - TaskSessionID: sessionID, - TaskID: "task-123", - AuthorType: models.MessageAuthorUser, - Content: "Comment 3", - CreatedAt: baseTime, - }) - - comments, hasMore, err := repo.ListMessagesPaginated(ctx, sessionID, ListMessagesOptions{ - Limit: 2, - Sort: "desc", - }) - if err != nil { - t.Fatalf("failed to list comments: %v", err) - } - if len(comments) != 2 { - t.Fatalf("expected 2 comments, got %d", len(comments)) - } - if !hasMore { - t.Error("expected hasMore to be true") - } - if comments[0].ID != "comment-3" { - t.Errorf("expected newest comment first, got %s", comments[0].ID) - } -} - -func TestMemoryRepository_MessageWithRequestsInput(t *testing.T) { - repo := NewMemoryRepository() - ctx := context.Background() - - // Create a task - task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"} - _ = repo.CreateTask(ctx, task) - sessionID := setupMemoryTestSession(ctx, repo, task.ID) - - // Create agent comment requesting input - comment := &models.Message{ - TaskSessionID: sessionID, - TaskID: "task-123", - AuthorType: models.MessageAuthorAgent, - AuthorID: "agent-123", - Content: "What should I do next?", - RequestsInput: true, - } - if err := repo.CreateMessage(ctx, comment); err != nil { - t.Fatalf("failed to create comment: %v", err) - } - - retrieved, _ := repo.GetMessage(ctx, comment.ID) - if !retrieved.RequestsInput { - t.Error("expected RequestsInput to be true") - } -} diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index de5dab1ca4..e78aa74931 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -237,7 +237,7 @@ func (r *SQLiteRepository) initSchema() error { CREATE TABLE IF NOT EXISTS task_sessions ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL DEFAULT '', + agent_execution_id TEXT NOT NULL DEFAULT '', container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT NOT NULL, executor_id TEXT DEFAULT '', @@ -1802,12 +1802,12 @@ func (r *SQLiteRepository) CreateTaskSession(ctx context.Context, session *model _, err = r.db.ExecContext(ctx, ` INSERT INTO task_sessions ( - id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, session.ID, session.TaskID, session.AgentInstanceID, session.ContainerID, session.AgentProfileID, + `, session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.Progress, session.ErrorMessage, string(metadataJSON), @@ -1828,13 +1828,13 @@ func (r *SQLiteRepository) GetTaskSession(ctx context.Context, id string) (*mode var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at FROM task_sessions WHERE id = ? `, id).Scan( - &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, + &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, @@ -1899,13 +1899,13 @@ func (r *SQLiteRepository) GetTaskSessionByTaskID(ctx context.Context, taskID st var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1 `, taskID).Scan( - &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, + &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, @@ -1970,7 +1970,7 @@ func (r *SQLiteRepository) GetActiveTaskSessionByTaskID(ctx context.Context, tas var completedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at @@ -1978,7 +1978,7 @@ func (r *SQLiteRepository) GetActiveTaskSessionByTaskID(ctx context.Context, tas WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC LIMIT 1 `, taskID).Scan( - &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, + &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, @@ -2058,12 +2058,12 @@ func (r *SQLiteRepository) UpdateTaskSession(ctx context.Context, session *model result, err := r.db.ExecContext(ctx, ` UPDATE task_sessions SET - agent_instance_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?, + agent_execution_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?, repository_id = ?, base_branch = ?, agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, state = ?, progress = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ? WHERE id = ? - `, session.AgentInstanceID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, + `, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.Progress, session.ErrorMessage, string(metadataJSON), session.CompletedAt, @@ -2102,10 +2102,10 @@ func (r *SQLiteRepository) UpdateTaskSessionState(ctx context.Context, id string return nil } -// ListAgentSessions returns all agent sessions for a task +// ListTaskSessions returns all agent sessions for a task func (r *SQLiteRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at @@ -2130,10 +2130,10 @@ func (r *SQLiteRepository) ListTaskSessions(ctx context.Context, taskID string) return sessions, nil } -// ListActiveAgentSessions returns all active agent sessions across all tasks +// ListActiveTaskSessions returns all active agent sessions across all tasks func (r *SQLiteRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, task_id, agent_instance_id, container_id, agent_profile_id, executor_id, environment_id, + SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, progress, error_message, metadata, started_at, completed_at, updated_at @@ -2227,7 +2227,7 @@ func (r *SQLiteRepository) scanTaskSessions(ctx context.Context, rows *sql.Rows) var completedAt sql.NullTime err := rows.Scan( - &session.ID, &session.TaskID, &session.AgentInstanceID, &session.ContainerID, &session.AgentProfileID, + &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 6b387661fb..141082be99 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -735,9 +735,9 @@ func TestSQLiteRepository_TaskSessionCRUD(t *testing.T) { // Create agent session session := &models.TaskSession{ - TaskID: "task-123", - AgentInstanceID: "instance-abc", - ContainerID: "container-xyz", + TaskID: "task-123", + AgentExecutionID: "execution-abc", + ContainerID: "container-xyz", AgentProfileID: "profile-123", ExecutorID: "executor-1", EnvironmentID: "env-1", diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 0d8131b57b..942cded36f 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -1507,13 +1507,45 @@ func (s *Service) DeleteMessage(ctx context.Context, id string) error { return nil } -// UpdateToolCallMessage updates a tool call message's status +// UpdateToolCallMessage updates a tool call message's status. +// It includes retry logic to handle race conditions where the complete event +// may arrive before the message has been created by the start event. func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result string) error { - message, err := s.repo.GetMessageByToolCallID(ctx, sessionID, toolCallID) + const maxRetries = 5 + const retryDelay = 100 * time.Millisecond + + var message *models.Message + var err error + + // Retry loop to handle race condition where complete event arrives before start event + // has finished creating the message in the database + for attempt := 0; attempt < maxRetries; attempt++ { + message, err = s.repo.GetMessageByToolCallID(ctx, sessionID, toolCallID) + if err == nil { + break // Found the message, proceed with update + } + + // If context is cancelled, don't retry + if ctx.Err() != nil { + return ctx.Err() + } + + // Log retry attempt (only warn on final failure) + if attempt < maxRetries-1 { + s.logger.Debug("tool call message not found, retrying", + zap.String("task_session_id", sessionID), + zap.String("tool_call_id", toolCallID), + zap.Int("attempt", attempt+1), + zap.Int("max_retries", maxRetries)) + time.Sleep(retryDelay) + } + } + if err != nil { - s.logger.Warn("tool call message not found for update", + s.logger.Warn("tool call message not found for update after retries", zap.String("task_session_id", sessionID), zap.String("tool_call_id", toolCallID), + zap.Int("retries", maxRetries), zap.Error(err)) return err } diff --git a/apps/backend/pkg/api/v1/agent.go b/apps/backend/pkg/api/v1/agent.go index 22d2616675..ed57fcffe9 100644 --- a/apps/backend/pkg/api/v1/agent.go +++ b/apps/backend/pkg/api/v1/agent.go @@ -2,7 +2,7 @@ package v1 import "time" -// AgentStatus represents the status of an agent instance +// AgentStatus represents the status of an agent execution type AgentStatus string const ( @@ -22,8 +22,8 @@ type ResourceLimits struct { DiskLimit string `json:"disk_limit"` } -// AgentInstance represents a running or completed agent container -type AgentInstance struct { +// AgentExecution represents a running or completed agent container +type AgentExecution struct { ID string `json:"id"` TaskID string `json:"task_id"` AgentProfileID string `json:"agent_profile_id"` @@ -57,8 +57,8 @@ type AgentType struct { // AgentLog represents a log entry from an agent type AgentLog struct { - ID int64 `json:"id"` - AgentInstanceID string `json:"agent_instance_id"` + ID int64 `json:"id"` + AgentExecutionID string `json:"agent_execution_id"` LogLevel string `json:"log_level"` Message string `json:"message"` Metadata map[string]interface{} `json:"metadata,omitempty"` diff --git a/apps/backend/pkg/codex/types.go b/apps/backend/pkg/codex/types.go index affd846b51..4fea9e9167 100644 --- a/apps/backend/pkg/codex/types.go +++ b/apps/backend/pkg/codex/types.go @@ -180,9 +180,16 @@ type Item struct { // For fileChange type Changes []FileChange `json:"changes,omitempty"` - // For reasoning type - Summary []string `json:"summary,omitempty"` - Content []string `json:"content,omitempty"` + // For reasoning type - content can be objects like [{type: "text", text: "..."}] + Summary []ContentPart `json:"summary,omitempty"` + Content []ContentPart `json:"content,omitempty"` +} + +// ContentPart represents a content part in a Codex item. +// This handles the OpenAI responses format where content is an array of typed objects. +type ContentPart struct { + Type string `json:"type,omitempty"` // "text", "output_text", "refusal", "input_text", etc. + Text string `json:"text,omitempty"` } // FileChange represents a file change in a fileChange item diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 3d710860dc..1a8fc6aa52 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -65,6 +65,7 @@ const ( ActionTaskExecution = "task.execution" ActionTaskSessionList = "task.session.list" ActionTaskSessionResume = "task.session.resume" + ActionTaskSessionStatus = "task.session.status" // Agent actions ActionAgentList = "agent.list" diff --git a/apps/backend/pkg/websocket/message.go b/apps/backend/pkg/websocket/message.go index 9b7fe6ec2d..254a80eb73 100644 --- a/apps/backend/pkg/websocket/message.go +++ b/apps/backend/pkg/websocket/message.go @@ -57,8 +57,6 @@ type ErrorPayload struct { Details map[string]interface{} `json:"details,omitempty"` } - - // NewRequest creates a new request message func NewRequest(id, action string, payload interface{}) (*Message, error) { data, err := json.Marshal(payload) diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index c95ad74403..0663b344a1 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -11,6 +11,7 @@ import { DEBUG_UI } from '@/lib/config'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useRepositories } from '@/hooks/use-repositories'; import { useTaskAgent } from '@/hooks/use-task-agent'; +import { useSessionResumption } from '@/hooks/use-session-resumption'; import { useAppStore } from '@/components/state-provider'; type TaskPageClientProps = { @@ -38,18 +39,37 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa }, [initialTask, kanbanTask]); const connectionStatus = useAppStore((state) => state.connection.status); - // Custom hooks for state management + // Session resumption hook - handles auto-resume on page reload + const { + resumptionState, + error: resumptionError, + taskSessionState: resumedSessionState, + worktreePath: resumedWorktreePath, + worktreeBranch: resumedWorktreeBranch, + } = useSessionResumption(task?.id ?? null, sessionId); + + // Regular task agent hook for non-resumed sessions const { isAgentRunning, isAgentLoading, - worktreePath, - worktreeBranch, + worktreePath: agentWorktreePath, + worktreeBranch: agentWorktreeBranch, taskSessionId, - taskSessionState, + taskSessionState: agentSessionState, handleStartAgent, handleStopAgent, } = useTaskAgent(task); + + // Merge state from resumption and regular agent hooks const activeSessionId = sessionId ?? taskSessionId; + const isResuming = resumptionState === 'checking' || resumptionState === 'resuming'; + const isResumed = resumptionState === 'resumed' || resumptionState === 'running'; + + // Both hooks read taskSessionState from the store, so prefer resumedSessionState as it's always + // updated when we have a sessionId from URL. For worktree, use resumed values when resuming/resumed. + const taskSessionState = resumedSessionState ?? agentSessionState; + const worktreePath = sessionId ? (resumedWorktreePath ?? agentWorktreePath) : agentWorktreePath; + const worktreeBranch = sessionId ? (resumedWorktreeBranch ?? agentWorktreeBranch) : agentWorktreeBranch; // Derive working state for debug overlay const isAgentWorking = @@ -107,6 +127,8 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa task_state: task?.state ?? null, task_session_state: taskSessionState ?? null, is_agent_working: isAgentWorking, + resumption_state: resumptionState, + resumption_error: resumptionError, }} /> )} @@ -118,8 +140,8 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa baseBranch={task?.repositories?.[0]?.base_branch ?? undefined} onStartAgent={handleStartAgent} onStopAgent={handleStopAgent} - isAgentRunning={isAgentRunning} - isAgentLoading={isAgentLoading} + isAgentRunning={isAgentRunning || isResumed} + isAgentLoading={isAgentLoading || isResuming} worktreePath={worktreePath} worktreeBranch={worktreeBranch} repositoryPath={repository?.local_path ?? null} @@ -128,6 +150,7 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index 9ed7908945..6eecd2f37a 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -9,9 +9,10 @@ import { getWebSocketClient } from '@/lib/ws/connection'; type ShellTerminalProps = { taskId: string; + sessionId: string | null; }; -export function ShellTerminal({ taskId }: ShellTerminalProps) { +export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); @@ -74,7 +75,7 @@ export function ShellTerminal({ taskId }: ShellTerminalProps) { // Handle user input terminal.onData((data) => { - send('shell.input', { task_id: taskId, data }); + send('shell.input', { task_id: taskId, session_id: sessionId, data }); }); // Handle resize @@ -89,7 +90,7 @@ export function ShellTerminal({ taskId }: ShellTerminalProps) { xtermRef.current = null; fitAddonRef.current = null; }; - }, [taskId, send]); + }, [taskId, sessionId, send]); // Write new output to terminal useEffect(() => { @@ -104,9 +105,14 @@ export function ShellTerminal({ taskId }: ShellTerminalProps) { }, [shellOutput]); // Subscribe to shell on mount to start the shell stream + // Uses retry logic to handle cases where agent isn't ready yet (e.g., session resumption) useEffect(() => { // Increment subscription ID to invalidate any pending requests const currentSubscriptionId = ++subscriptionIdRef.current; + let retryCount = 0; + const maxRetries = 10; + const retryDelay = 1000; // 1 second between retries + let retryTimeout: ReturnType | null = null; // Clear any stale output before subscribing to get fresh buffer storeApi.getState().clearShellOutput(taskId); @@ -117,10 +123,15 @@ export function ShellTerminal({ taskId }: ShellTerminalProps) { xtermRef.current.clear(); } - const client = getWebSocketClient(); - if (client) { + const attemptSubscribe = () => { + const client = getWebSocketClient(); + if (!client) return; + + // Check if this subscription is still valid + if (subscriptionIdRef.current !== currentSubscriptionId) return; + client - .request<{ success: boolean; buffer?: string }>('shell.subscribe', { task_id: taskId }) + .request<{ success: boolean; buffer?: string }>('shell.subscribe', { task_id: taskId, session_id: sessionId }) .then((response) => { // Only process if this is still the current subscription (handles React Strict Mode) if (subscriptionIdRef.current !== currentSubscriptionId) return; @@ -132,10 +143,26 @@ export function ShellTerminal({ taskId }: ShellTerminalProps) { }) .catch((err) => { if (subscriptionIdRef.current !== currentSubscriptionId) return; - console.error('Failed to subscribe to shell:', err); + + // Retry if agent not ready yet (common during session resumption) + if (retryCount < maxRetries) { + retryCount++; + console.log(`[ShellTerminal] Retrying shell subscription (${retryCount}/${maxRetries})...`); + retryTimeout = setTimeout(attemptSubscribe, retryDelay); + } else { + console.error('Failed to subscribe to shell after retries:', err); + } }); - } - }, [taskId, storeApi]); + }; + + attemptSubscribe(); + + return () => { + if (retryTimeout) { + clearTimeout(retryTimeout); + } + }; + }, [taskId, sessionId, storeApi]); return (
Promise; }; export const TaskLayout = memo(function TaskLayout({ taskId, + sessionId, onSendMessage, }: TaskLayoutProps) { const [horizontalLayout, setHorizontalLayout] = useState<[number, number]>( @@ -63,7 +65,7 @@ export const TaskLayout = memo(function TaskLayout({ - +
diff --git a/apps/web/components/task/task-right-panel.tsx b/apps/web/components/task/task-right-panel.tsx index 98659535b1..34b3a1b35b 100644 --- a/apps/web/components/task/task-right-panel.tsx +++ b/apps/web/components/task/task-right-panel.tsx @@ -13,11 +13,12 @@ import { ShellTerminal } from '@/components/task/shell-terminal'; type TaskRightPanelProps = { topPanel: ReactNode; taskId: string; + sessionId: string | null; }; const DEFAULT_RIGHT_LAYOUT: [number, number] = [55, 45]; -const TaskRightPanel = memo(function TaskRightPanel({ topPanel, taskId }: TaskRightPanelProps) { +const TaskRightPanel = memo(function TaskRightPanel({ topPanel, taskId, sessionId }: TaskRightPanelProps) { const [rightLayout, setRightLayout] = useState<[number, number]>(() => getLocalStorage('task-layout-right', DEFAULT_RIGHT_LAYOUT) ); @@ -179,7 +180,7 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, taskId }: TaskRi {terminalIds.map((id) => (
- +
))} diff --git a/apps/web/hooks/use-session-resumption.ts b/apps/web/hooks/use-session-resumption.ts new file mode 100644 index 0000000000..5a73f799da --- /dev/null +++ b/apps/web/hooks/use-session-resumption.ts @@ -0,0 +1,249 @@ +import { useCallback, useEffect, useState, useRef } from 'react'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { useAppStore } from '@/components/state-provider'; +import type { TaskSessionState } from '@/lib/types/http'; + +export type SessionStatus = { + session_id: string; + task_id: string; + state: string; + agent_profile_id?: string; + is_agent_running: boolean; + is_resumable: boolean; + needs_resume: boolean; + resume_reason?: string; + acp_session_id?: string; + worktree_path?: string; + worktree_branch?: string; + error?: string; +}; + +export type ResumptionState = 'idle' | 'checking' | 'resuming' | 'resumed' | 'running' | 'error'; + +interface UseSessionResumptionReturn { + resumptionState: ResumptionState; + sessionStatus: SessionStatus | null; + error: string | null; + taskSessionState: TaskSessionState | null; + worktreePath: string | null; + worktreeBranch: string | null; + resumeSession: () => Promise; + startNewSession: (agentProfileId: string) => Promise; +} + +/** + * Hook for handling session resumption on page reload. + * When a sessionId is provided (from URL), it checks the session status + * and automatically resumes if needed. + */ +export function useSessionResumption( + taskId: string | null, + sessionId: string | null +): UseSessionResumptionReturn { + const [resumptionState, setResumptionState] = useState('idle'); + const [sessionStatus, setSessionStatus] = useState(null); + const [error, setError] = useState(null); + const [worktreePath, setWorktreePath] = useState(null); + const [worktreeBranch, setWorktreeBranch] = useState(null); + const connectionStatus = useAppStore((state) => state.connection.status); + // Read task session state from store so WebSocket updates are reflected + const taskSessionState = useAppStore((state) => taskId ? state.taskSessionStatesByTaskId[taskId] ?? null : null); + const setStoreTaskSessionState = useAppStore((state) => state.setTaskSessionState); + const hasAttemptedResume = useRef(false); + + // Check session status and auto-resume if needed + useEffect(() => { + if (!taskId || !sessionId) return; + if (connectionStatus !== 'connected') return; + if (hasAttemptedResume.current) return; + + const checkAndResume = async () => { + const client = getWebSocketClient(); + if (!client) return; + + hasAttemptedResume.current = true; + setResumptionState('checking'); + setError(null); + + try { + // 1. Check session status + console.log('[useSessionResumption] Checking session status:', { taskId, sessionId }); + const status = await client.request('task.session.status', { + task_id: taskId, + task_session_id: sessionId, + }); + console.log('[useSessionResumption] Session status:', status); + setSessionStatus(status); + + if (status.error) { + setResumptionState('error'); + setError(status.error); + return; + } + + // Update worktree info + setWorktreePath(status.worktree_path ?? null); + setWorktreeBranch(status.worktree_branch ?? null); + if (taskId && status.state) { + setStoreTaskSessionState(taskId, status.state as TaskSessionState); + } + + // 2. If agent is already running, we're good + if (status.is_agent_running) { + console.log('[useSessionResumption] Agent already running'); + setResumptionState('running'); + return; + } + + // 3. If session needs resume and is resumable, auto-resume + if (status.needs_resume && status.is_resumable) { + console.log('[useSessionResumption] Auto-resuming session'); + setResumptionState('resuming'); + + const resumeResp = await client.request<{ + success: boolean; + state?: string; + worktree_path?: string; + worktree_branch?: string; + error?: string; + }>('task.session.resume', { + task_id: taskId, + task_session_id: sessionId, + }, 30000); + + if (resumeResp.success) { + console.log('[useSessionResumption] Session resumed successfully'); + setResumptionState('resumed'); + if (taskId && resumeResp.state) { + setStoreTaskSessionState(taskId, resumeResp.state as TaskSessionState); + } + if (resumeResp.worktree_path) { + setWorktreePath(resumeResp.worktree_path); + } + if (resumeResp.worktree_branch) { + setWorktreeBranch(resumeResp.worktree_branch); + } + } else { + console.error('[useSessionResumption] Resume failed:', resumeResp.error); + setResumptionState('error'); + setError(resumeResp.error ?? 'Failed to resume session'); + } + } else if (!status.is_resumable) { + // Session cannot be resumed (terminal state or missing data) + console.log('[useSessionResumption] Session not resumable'); + setResumptionState('idle'); + } else { + // Session exists but doesn't need resume (already running or other state) + setResumptionState('idle'); + } + } catch (err) { + console.error('[useSessionResumption] Error checking/resuming session:', err); + setResumptionState('error'); + setError(err instanceof Error ? err.message : 'Unknown error'); + } + }; + + checkAndResume(); + }, [taskId, sessionId, connectionStatus, setStoreTaskSessionState]); + + // Manual resume function + const resumeSession = useCallback(async (): Promise => { + if (!taskId || !sessionId) return false; + + const client = getWebSocketClient(); + if (!client) return false; + + setResumptionState('resuming'); + setError(null); + + try { + const response = await client.request<{ + success: boolean; + state?: string; + worktree_path?: string; + worktree_branch?: string; + error?: string; + }>('task.session.resume', { + task_id: taskId, + task_session_id: sessionId, + }, 30000); + + if (response.success) { + setResumptionState('resumed'); + if (taskId && response.state) { + setStoreTaskSessionState(taskId, response.state as TaskSessionState); + } + if (response.worktree_path) { + setWorktreePath(response.worktree_path); + } + if (response.worktree_branch) { + setWorktreeBranch(response.worktree_branch); + } + return true; + } else { + setResumptionState('error'); + setError(response.error ?? 'Failed to resume session'); + return false; + } + } catch (err) { + setResumptionState('error'); + setError(err instanceof Error ? err.message : 'Unknown error'); + return false; + } + }, [taskId, sessionId, setStoreTaskSessionState]); + + // Start new session function (fallback when resume fails) + const startNewSession = useCallback(async (agentProfileId: string): Promise => { + if (!taskId) return false; + + const client = getWebSocketClient(); + if (!client) return false; + + setResumptionState('resuming'); + setError(null); + + try { + const response = await client.request<{ + success: boolean; + task_session_id?: string; + state?: string; + worktree_path?: string; + worktree_branch?: string; + }>('orchestrator.start', { + task_id: taskId, + agent_profile_id: agentProfileId, + }, 15000); + + if (response.success) { + setResumptionState('resumed'); + if (taskId && response.state) { + setStoreTaskSessionState(taskId, response.state as TaskSessionState); + } + if (response.worktree_path) { + setWorktreePath(response.worktree_path); + } + if (response.worktree_branch) { + setWorktreeBranch(response.worktree_branch); + } + return true; + } + return false; + } catch (err) { + setResumptionState('error'); + setError(err instanceof Error ? err.message : 'Unknown error'); + return false; + } + }, [taskId, setStoreTaskSessionState]); + + return { + resumptionState, + sessionStatus, + error, + taskSessionState, + worktreePath, + worktreeBranch, + resumeSession, + startNewSession, + }; +} +