572 lines
15 KiB
Go
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
|
|
}
|
|
|