Files
kandev/apps/backend/internal/agentctl/server/instance/manager.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

239 lines
6.2 KiB
Go

// Package instance provides utilities for managing multi-agent instances.
package instance
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"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"
)
// ServerFactory creates an HTTP handler for an instance given its config and process manager.
type ServerFactory func(cfg *config.InstanceConfig, procMgr *process.Manager, log *logger.Logger) http.Handler
// Manager manages multiple agent instances.
// It handles creation, tracking, and removal of agent instances,
// each with their own HTTP server on a dedicated port.
type Manager struct {
config *config.Config
logger *logger.Logger
instances map[string]*Instance
portAlloc *PortAllocator
serverFactory ServerFactory
mu sync.RWMutex
}
// NewManager creates a new instance manager.
func NewManager(cfg *config.Config, log *logger.Logger) *Manager {
return &Manager{
config: cfg,
logger: log.WithFields(zap.String("component", "instance-manager")),
instances: make(map[string]*Instance),
portAlloc: NewPortAllocator(cfg.Ports.Base, cfg.Ports.Max),
}
}
// SetServerFactory sets the factory function for creating HTTP handlers for instances.
// This must be called before creating any instances.
func (m *Manager) SetServerFactory(factory ServerFactory) {
m.serverFactory = factory
}
// CreateInstance creates a new agent instance.
func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*CreateResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Check if max instances reached
if len(m.instances) >= m.config.MaxInstances {
return nil, fmt.Errorf("maximum number of instances (%d) reached", m.config.MaxInstances)
}
// Generate ID if not provided
id := req.ID
if id == "" {
id = uuid.New().String()
}
// Check if ID already exists
if _, exists := m.instances[id]; exists {
return nil, fmt.Errorf("instance with ID %s already exists", id)
}
// Allocate a port
port, err := m.portAlloc.Allocate(id)
if err != nil {
return nil, fmt.Errorf("failed to allocate port: %w", err)
}
// Determine agent command, adding workspace flag if needed
agentCmd := req.AgentCommand
if agentCmd == "" {
agentCmd = m.config.Defaults.AgentCommand
}
if req.WorkspacePath != "" && req.WorkspaceFlag != "" {
if !strings.Contains(agentCmd, req.WorkspaceFlag) {
agentCmd = agentCmd + " " + req.WorkspaceFlag + " " + req.WorkspacePath
}
}
// Build overrides from request
autoStart := req.AutoStart
overrides := &config.InstanceOverrides{
Protocol: agent.Protocol(req.Protocol),
AgentCommand: agentCmd,
WorkDir: req.WorkspacePath,
AutoStart: &autoStart,
Env: config.CollectAgentEnv(req.Env),
}
// Create instance config using the unified method
instanceCfg := m.config.NewInstanceConfig(port, overrides)
// Create process manager
procMgr := process.NewManager(instanceCfg, m.logger)
// Create HTTP handler using factory
var handler http.Handler
if m.serverFactory != nil {
handler = m.serverFactory(instanceCfg, procMgr, m.logger)
} else {
// Default to a simple handler if no factory is set
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
if _, err := w.Write([]byte("server factory not configured")); err != nil {
m.logger.Debug("failed to write default response", zap.Error(err))
}
})
}
// Create HTTP server
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: handler,
}
// Start HTTP server in background
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
m.logger.Error("instance server error", zap.String("instance_id", id), zap.Error(err))
}
}()
// Create and store instance
inst := &Instance{
ID: id,
Port: port,
Status: "running",
WorkspacePath: req.WorkspacePath,
AgentCommand: agentCmd,
Env: req.Env,
CreatedAt: time.Now(),
manager: procMgr,
server: httpServer,
}
m.instances[id] = inst
m.logger.Info("created instance",
zap.String("instance_id", id),
zap.Int("port", port),
zap.String("workspace", req.WorkspacePath))
return &CreateResponse{
ID: id,
Port: port,
}, nil
}
// GetInstance returns an instance by ID.
func (m *Manager) GetInstance(id string) (*Instance, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
inst, ok := m.instances[id]
return inst, ok
}
// ListInstances returns info for all instances.
func (m *Manager) ListInstances() []*InstanceInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*InstanceInfo, 0, len(m.instances))
for _, inst := range m.instances {
result = append(result, inst.Info())
}
return result
}
// StopInstance stops and removes an instance by ID.
func (m *Manager) StopInstance(ctx context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
inst, ok := m.instances[id]
if !ok {
return fmt.Errorf("instance %s not found", id)
}
// Stop the process manager
if inst.manager != nil {
if err := inst.manager.Stop(ctx); err != nil {
m.logger.Warn("error stopping process manager",
zap.String("instance_id", id),
zap.Error(err))
}
}
// Shutdown HTTP server
if inst.server != nil {
if err := inst.server.Shutdown(ctx); err != nil {
m.logger.Warn("error shutting down HTTP server",
zap.String("instance_id", id),
zap.Error(err))
}
}
// Release port
m.portAlloc.Release(inst.Port)
// Remove from instances map
delete(m.instances, id)
m.logger.Info("stopped instance",
zap.String("instance_id", id),
zap.Int("port", inst.Port))
return nil
}
// Shutdown stops all instances gracefully.
func (m *Manager) Shutdown(ctx context.Context) error {
m.mu.Lock()
ids := make([]string, 0, len(m.instances))
for id := range m.instances {
ids = append(ids, id)
}
m.mu.Unlock()
var lastErr error
for _, id := range ids {
if err := m.StopInstance(ctx, id); err != nil {
m.logger.Error("error stopping instance during shutdown",
zap.String("instance_id", id),
zap.Error(err))
lastErr = err
}
}
return lastErr
}