Files
kandev/apps/backend/internal/agentctl/process/manager.go
T
Carlos Florêncio e1bce95bac feat: golang linter
2026-01-15 19:05:16 +00:00

572 lines
15 KiB
Go

// Package process manages the agent subprocess lifecycle
package process
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"sync"
"sync/atomic"
"time"
"github.com/kandev/kandev/internal/agentctl/adapter"
"github.com/kandev/kandev/internal/agentctl/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/agent"
"go.uber.org/zap"
)
// Status represents the agent process status
type Status string
const (
StatusStopped Status = "stopped"
StatusStarting Status = "starting"
StatusRunning Status = "running"
StatusPaused Status = "paused"
StatusStopping Status = "stopping"
StatusError Status = "error"
)
// errorWrapper wraps an error so it can be stored in atomic.Value (which cannot store nil)
type errorWrapper struct {
err error
}
// PendingPermission represents a permission request waiting for user response
type PendingPermission struct {
ID string
Request *adapter.PermissionRequest
ResponseCh chan *adapter.PermissionResponse
CreatedAt time.Time
}
// PermissionNotification is sent when the agent requests permission
type PermissionNotification struct {
PendingID string `json:"pending_id"`
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []adapter.PermissionOption `json:"options"`
CreatedAt time.Time `json:"created_at"`
}
// Manager manages the agent subprocess
type Manager struct {
cfg *config.Config
logger *logger.Logger
// Process state
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
status atomic.Value // Status
exitCode atomic.Int32
exitErr atomic.Value // error
// Workspace tracker for git status and file changes
workspaceTracker *WorkspaceTracker
// Protocol adapter for agent communication
adapter adapter.AgentAdapter
// Session update notifications (protocol-agnostic)
updatesCh chan adapter.SessionUpdate
// Permission request notifications (sent to backend)
permissionCh chan *PermissionNotification
// Pending permission requests waiting for user response
pendingPermissions map[string]*PendingPermission
permissionMu sync.RWMutex
// Synchronization
mu sync.RWMutex
wg sync.WaitGroup
stopCh chan struct{}
doneCh chan struct{}
startMu sync.Mutex
}
// NewManager creates a new process manager
func NewManager(cfg *config.Config, log *logger.Logger) *Manager {
m := &Manager{
cfg: cfg,
logger: log.WithFields(zap.String("component", "process-manager")),
workspaceTracker: NewWorkspaceTracker(cfg.WorkDir, log),
updatesCh: make(chan adapter.SessionUpdate, 100),
permissionCh: make(chan *PermissionNotification, 10),
pendingPermissions: make(map[string]*PendingPermission),
}
m.status.Store(StatusStopped)
m.exitCode.Store(-1)
return m
}
// Status returns the current process status
func (m *Manager) Status() Status {
return m.status.Load().(Status)
}
// ExitCode returns the exit code (-1 if not exited)
func (m *Manager) ExitCode() int {
return int(m.exitCode.Load())
}
// ExitError returns the exit error if any
func (m *Manager) ExitError() error {
if v := m.exitErr.Load(); v != nil {
if w, ok := v.(errorWrapper); ok {
return w.err
}
}
return nil
}
// GetWorkspaceTracker returns the workspace tracker for git status and file monitoring
func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker {
return m.workspaceTracker
}
// Start starts the agent process
func (m *Manager) Start(ctx context.Context) error {
m.startMu.Lock()
defer m.startMu.Unlock()
if m.Status() == StatusRunning || m.Status() == StatusStarting {
return fmt.Errorf("agent is already running")
}
m.logger.Info("starting agent process",
zap.String("protocol", string(m.cfg.Protocol)),
zap.Strings("args", m.cfg.AgentArgs),
zap.String("workdir", m.cfg.WorkDir))
m.status.Store(StatusStarting)
m.exitCode.Store(-1)
m.exitErr.Store(errorWrapper{err: nil})
// Create command
if len(m.cfg.AgentArgs) == 0 {
m.status.Store(StatusError)
return fmt.Errorf("no agent command configured")
}
// NOTE: We intentionally don't use exec.CommandContext here because we don't want
// the HTTP request context to kill the agent process when the request completes.
m.cmd = exec.Command(m.cfg.AgentArgs[0], m.cfg.AgentArgs[1:]...)
m.cmd.Dir = m.cfg.WorkDir
m.cmd.Env = m.cfg.AgentEnv
// Set up pipes
var err error
m.stdin, err = m.cmd.StdinPipe()
if err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
m.stdout, err = m.cmd.StdoutPipe()
if err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
m.stderr, err = m.cmd.StderrPipe()
if err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Start the process
m.logger.Info("starting agent process",
zap.Strings("args", m.cfg.AgentArgs),
zap.String("workdir", m.cfg.WorkDir),
zap.Int("env_count", len(m.cfg.AgentEnv)))
if err := m.cmd.Start(); err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to start agent: %w", err)
}
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
// Create protocol adapter based on configuration
if err := m.createAdapter(); err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create adapter: %w", err)
}
// Start stderr reader and exit waiter
m.wg.Add(2)
go m.readStderr()
go m.waitForExit()
// Forward adapter updates to our channel
m.wg.Add(1)
go m.forwardUpdates()
// Start workspace tracker with background context (not tied to HTTP request)
m.workspaceTracker.Start(context.Background())
m.status.Store(StatusRunning)
m.logger.Info("agent process started", zap.Int("pid", m.cmd.Process.Pid))
return nil
}
// createAdapter creates the appropriate protocol adapter based on configuration
func (m *Manager) createAdapter() error {
protocol := m.cfg.Protocol
if protocol == "" {
protocol = agent.ProtocolACP // Default to ACP
}
adapterCfg := &adapter.Config{
WorkDir: m.cfg.WorkDir,
AutoApprove: m.cfg.AutoApprovePermissions,
}
switch protocol {
case agent.ProtocolACP:
m.adapter = adapter.NewACPAdapter(m.stdin, m.stdout, adapterCfg, m.logger)
case agent.ProtocolCodex:
m.adapter = adapter.NewCodexAdapter(m.stdin, m.stdout, adapterCfg, m.logger)
default:
return fmt.Errorf("unsupported protocol: %s", protocol)
}
// Set the permission handler
m.adapter.SetPermissionHandler(m.handlePermissionRequest)
return nil
}
// forwardUpdates forwards updates from the adapter to the manager's channel
func (m *Manager) forwardUpdates() {
defer m.wg.Done()
updatesCh := m.adapter.Updates()
for {
select {
case update, ok := <-updatesCh:
if !ok {
return
}
select {
case m.updatesCh <- update:
default:
m.logger.Warn("updates channel full, dropping notification")
}
case <-m.stopCh:
return
}
}
}
// GetUpdates returns the channel for session update notifications
func (m *Manager) GetUpdates() <-chan adapter.SessionUpdate {
return m.updatesCh
}
// GetAdapter returns the protocol adapter
func (m *Manager) GetAdapter() adapter.AgentAdapter {
m.mu.RLock()
defer m.mu.RUnlock()
return m.adapter
}
// GetSessionID returns the current session ID from the adapter.
// The adapter is the single source of truth for session ID.
func (m *Manager) GetSessionID() string {
m.mu.RLock()
a := m.adapter
m.mu.RUnlock()
if a != nil {
return a.GetSessionID()
}
return ""
}
// Stop stops the agent process
func (m *Manager) Stop(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
status := m.Status()
if status == StatusStopped || status == StatusStopping {
return nil
}
m.logger.Info("stopping agent process")
m.status.Store(StatusStopping)
// Stop workspace tracker
if m.workspaceTracker != nil {
m.workspaceTracker.Stop()
}
// Close the adapter
if m.adapter != nil {
if err := m.adapter.Close(); err != nil {
m.logger.Debug("failed to close adapter", zap.Error(err))
}
}
// Close stop channel to signal readers
if m.stopCh != nil {
close(m.stopCh)
}
// Close stdin to signal EOF to agent
if m.stdin != nil {
if err := m.stdin.Close(); err != nil {
m.logger.Debug("failed to close stdin", zap.Error(err))
}
}
// Wait for process to exit with timeout
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
select {
case <-done:
m.logger.Info("agent process stopped gracefully")
case <-ctx.Done():
// Force kill
if m.cmd != nil && m.cmd.Process != nil {
m.logger.Warn("force killing agent process")
if err := m.cmd.Process.Kill(); err != nil {
m.logger.Warn("failed to kill agent process", zap.Error(err))
}
}
}
m.status.Store(StatusStopped)
return nil
}
// readStderr reads and logs stderr from the agent
func (m *Manager) readStderr() {
defer m.wg.Done()
scanner := bufio.NewScanner(m.stderr)
for scanner.Scan() {
line := scanner.Text()
m.logger.Debug("agent stderr", zap.String("line", line))
}
if err := scanner.Err(); err != nil {
m.logger.Debug("stderr reader error", zap.Error(err))
}
}
// waitForExit waits for the process to exit
func (m *Manager) waitForExit() {
defer m.wg.Done()
defer close(m.doneCh)
err := m.cmd.Wait()
if err != nil {
m.exitErr.Store(errorWrapper{err: err})
if exitErr, ok := err.(*exec.ExitError); ok {
m.exitCode.Store(int32(exitErr.ExitCode()))
}
m.logger.Info("agent process exited with error", zap.Error(err))
} else {
m.exitCode.Store(0)
m.logger.Info("agent process exited successfully")
}
m.status.Store(StatusStopped)
}
// GetProcessInfo returns information about the process
func (m *Manager) GetProcessInfo() map[string]interface{} {
info := map[string]interface{}{
"status": string(m.Status()),
"exit_code": m.ExitCode(),
}
if m.cmd != nil && m.cmd.Process != nil {
info["pid"] = m.cmd.Process.Pid
}
if err := m.ExitError(); err != nil {
info["exit_error"] = err.Error()
}
return info
}
// handlePermissionRequest handles permission requests from the agent
// It stores the pending request and waits for a response from the backend
func (m *Manager) handlePermissionRequest(ctx context.Context, req *adapter.PermissionRequest) (*adapter.PermissionResponse, error) {
// Generate a unique ID for this permission request
pendingID := fmt.Sprintf("%s-%s-%d", req.SessionID, req.ToolCallID, time.Now().UnixNano())
m.logger.Info("handling permission request",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.String("tool_call_id", req.ToolCallID),
zap.String("title", req.Title),
zap.Bool("auto_approve", m.cfg.AutoApprovePermissions))
// If auto-approve is enabled, immediately approve with the first "allow" option
if m.cfg.AutoApprovePermissions {
return m.autoApprovePermission(req)
}
// Create pending permission with response channel
pending := &PendingPermission{
ID: pendingID,
Request: req,
ResponseCh: make(chan *adapter.PermissionResponse, 1),
CreatedAt: time.Now(),
}
// Store pending permission
m.permissionMu.Lock()
m.pendingPermissions[pendingID] = pending
m.permissionMu.Unlock()
// Clean up when done
defer func() {
m.permissionMu.Lock()
delete(m.pendingPermissions, pendingID)
m.permissionMu.Unlock()
}()
// Send notification to backend via updates channel
// We use a custom notification type that the backend will recognize
m.sendPermissionNotification(pending)
// Wait for response with timeout
select {
case resp := <-pending.ResponseCh:
m.logger.Info("received permission response",
zap.String("pending_id", pendingID),
zap.String("option_id", resp.OptionID),
zap.Bool("cancelled", resp.Cancelled))
return resp, nil
case <-ctx.Done():
m.logger.Warn("permission request context cancelled",
zap.String("pending_id", pendingID))
return &adapter.PermissionResponse{Cancelled: true}, nil
case <-time.After(5 * time.Minute):
m.logger.Warn("permission request timed out",
zap.String("pending_id", pendingID))
return &adapter.PermissionResponse{Cancelled: true}, nil
}
}
// autoApprovePermission automatically approves a permission request
// by selecting the first "allow" option, or the first option if no allow option exists
func (m *Manager) autoApprovePermission(req *adapter.PermissionRequest) (*adapter.PermissionResponse, error) {
if len(req.Options) == 0 {
m.logger.Warn("no options available for auto-approve, cancelling")
return &adapter.PermissionResponse{Cancelled: true}, nil
}
// Find the first "allow" option
var selectedOption *adapter.PermissionOption
for i := range req.Options {
opt := &req.Options[i]
if opt.Kind == "allow_once" || opt.Kind == "allow_always" {
selectedOption = opt
break
}
}
// If no allow option, use the first option
if selectedOption == nil {
selectedOption = &req.Options[0]
}
m.logger.Info("auto-approving permission request",
zap.String("option_id", selectedOption.OptionID),
zap.String("option_name", selectedOption.Name),
zap.String("kind", selectedOption.Kind))
return &adapter.PermissionResponse{
OptionID: selectedOption.OptionID,
}, nil
}
// sendPermissionNotification sends a permission request notification to the backend
func (m *Manager) sendPermissionNotification(pending *PendingPermission) {
notification := &PermissionNotification{
PendingID: pending.ID,
SessionID: pending.Request.SessionID,
ToolCallID: pending.Request.ToolCallID,
Title: pending.Request.Title,
Options: pending.Request.Options,
CreatedAt: pending.CreatedAt,
}
m.logger.Info("sending permission notification to backend",
zap.String("pending_id", pending.ID),
zap.String("title", pending.Request.Title))
select {
case m.permissionCh <- notification:
// Sent successfully
default:
m.logger.Warn("permission channel full, dropping notification",
zap.String("pending_id", pending.ID))
}
}
// GetPermissionRequests returns the channel for permission request notifications
func (m *Manager) GetPermissionRequests() <-chan *PermissionNotification {
return m.permissionCh
}
// RespondToPermission responds to a pending permission request
func (m *Manager) RespondToPermission(pendingID string, optionID string, cancelled bool) error {
m.permissionMu.RLock()
pending, ok := m.pendingPermissions[pendingID]
m.permissionMu.RUnlock()
if !ok {
return fmt.Errorf("pending permission not found: %s", pendingID)
}
m.logger.Info("responding to permission request",
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
// Send response (non-blocking since channel is buffered)
select {
case pending.ResponseCh <- &adapter.PermissionResponse{
OptionID: optionID,
Cancelled: cancelled,
}:
return nil
default:
return fmt.Errorf("response channel full for pending permission: %s", pendingID)
}
}
// GetPendingPermissions returns all pending permission requests
func (m *Manager) GetPendingPermissions() []*PendingPermission {
m.permissionMu.RLock()
defer m.permissionMu.RUnlock()
result := make([]*PendingPermission, 0, len(m.pendingPermissions))
for _, p := range m.pendingPermissions {
result = append(result, p)
}
return result
}