Files
kandev/apps/backend/internal/orchestrator/controller/controller.go
T
João Salavisa 2e108b040f refactor: session resumption cleanup and AgentInstance → AgentExecution rename (#23)
* 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
2026-01-16 22:14:19 +00:00

200 lines
6.1 KiB
Go

// Package controller provides the business logic coordination layer for the orchestrator.
package controller
import (
"context"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/dto"
)
// Controller coordinates orchestrator business logic
type Controller struct {
service *orchestrator.Service
}
// NewController creates a new orchestrator controller
func NewController(svc *orchestrator.Service) *Controller {
return &Controller{
service: svc,
}
}
// GetStatus returns the orchestrator status
func (c *Controller) GetStatus(ctx context.Context, req dto.GetStatusRequest) (dto.StatusResponse, error) {
status := c.service.GetStatus()
return dto.StatusResponse{
Running: status.Running,
ActiveAgents: status.ActiveAgents,
QueuedTasks: status.QueuedTasks,
MaxConcurrent: 0, // Not available from service Status; would need scheduler access
}, nil
}
// GetQueue returns the queued tasks
func (c *Controller) GetQueue(ctx context.Context, req dto.GetQueueRequest) (dto.QueueResponse, error) {
queuedTasks := c.service.GetQueuedTasks()
tasks := make([]dto.QueuedTaskDTO, 0, len(queuedTasks))
for _, qt := range queuedTasks {
tasks = append(tasks, dto.QueuedTaskDTO{
TaskID: qt.TaskID,
Priority: qt.Priority,
QueuedAt: qt.QueuedAt.Format("2006-01-02T15:04:05Z"),
})
}
return dto.QueueResponse{
Tasks: tasks,
Total: len(tasks),
}, nil
}
// TriggerTask triggers a task for execution
func (c *Controller) TriggerTask(ctx context.Context, req dto.TriggerTaskRequest) (dto.TriggerTaskResponse, error) {
// For now, just return success - the service would need task details
return dto.TriggerTaskResponse{
Success: true,
Message: "Task triggered",
TaskID: req.TaskID,
}, nil
}
// StartTask starts task execution
func (c *Controller) StartTask(ctx context.Context, req dto.StartTaskRequest) (dto.StartTaskResponse, error) {
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.Priority)
if err != nil {
return dto.StartTaskResponse{}, err
}
resp := dto.StartTaskResponse{
Success: true,
TaskID: execution.TaskID,
AgentExecutionID: execution.AgentExecutionID,
TaskSessionID: execution.SessionID,
State: string(execution.SessionState),
}
// Include worktree info if available
if execution.WorktreePath != "" {
resp.WorktreePath = &execution.WorktreePath
}
if execution.WorktreeBranch != "" {
resp.WorktreeBranch = &execution.WorktreeBranch
}
return resp, nil
}
// ResumeTaskSession resumes a specific task session execution.
func (c *Controller) ResumeTaskSession(ctx context.Context, req dto.ResumeTaskSessionRequest) (dto.ResumeTaskSessionResponse, error) {
execution, err := c.service.ResumeTaskSession(ctx, req.TaskID, req.TaskSessionID)
if err != nil {
return dto.ResumeTaskSessionResponse{}, err
}
resp := dto.ResumeTaskSessionResponse{
Success: true,
TaskID: execution.TaskID,
AgentExecutionID: execution.AgentExecutionID,
TaskSessionID: execution.SessionID,
State: string(execution.SessionState),
}
if execution.WorktreePath != "" {
resp.WorktreePath = &execution.WorktreePath
}
if execution.WorktreeBranch != "" {
resp.WorktreeBranch = &execution.WorktreeBranch
}
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
if reason == "" {
reason = "stopped via API"
}
if err := c.service.StopTask(ctx, req.TaskID, reason, req.Force); err != nil {
return dto.SuccessResponse{}, err
}
return dto.SuccessResponse{Success: true}, nil
}
// PromptTask sends a prompt to a running task
func (c *Controller) PromptTask(ctx context.Context, req dto.PromptTaskRequest) (dto.PromptTaskResponse, error) {
result, err := c.service.PromptTask(ctx, req.TaskID, req.Prompt)
if err != nil {
return dto.PromptTaskResponse{}, err
}
return dto.PromptTaskResponse{
Success: true,
StopReason: result.StopReason,
}, nil
}
// CompleteTask marks a task as complete
func (c *Controller) CompleteTask(ctx context.Context, req dto.CompleteTaskRequest) (dto.CompleteTaskResponse, error) {
if err := c.service.CompleteTask(ctx, req.TaskID); err != nil {
return dto.CompleteTaskResponse{}, err
}
return dto.CompleteTaskResponse{
Success: true,
Message: "task completed",
}, nil
}
// RespondToPermission responds to a permission request
func (c *Controller) RespondToPermission(ctx context.Context, req dto.PermissionRespondRequest) (dto.PermissionRespondResponse, error) {
if err := c.service.RespondToPermission(ctx, req.TaskID, req.PendingID, req.OptionID, req.Cancelled); err != nil {
return dto.PermissionRespondResponse{}, err
}
return dto.PermissionRespondResponse{
Success: true,
TaskID: req.TaskID,
PendingID: req.PendingID,
}, nil
}
// GetTaskExecution returns the execution state for a task
func (c *Controller) GetTaskExecution(ctx context.Context, req dto.GetTaskExecutionRequest) (dto.TaskExecutionResponse, error) {
execution, exists := c.service.GetTaskExecution(req.TaskID)
if !exists {
return dto.TaskExecutionResponse{
HasExecution: false,
TaskID: req.TaskID,
}, nil
}
return dto.TaskExecutionResponse{
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
}