Files
kandev/apps/backend/internal/agentctl/process/manager.go
T
jcfs 20f5f43fb3 feat: complete acp-go-sdk integration with task state updates
- Use acp-go-sdk for all ACP communication in agentctl
- Mark agent as completed when Prompt() returns successfully
- Update task state to COMPLETED when agent completes
- Update task state to FAILED when agent fails and retries exhausted
- Enhanced E2E test to verify agent actually creates files
- E2E test now validates full flow: task creation -> agent work -> file creation -> task completion
2026-01-10 16:46:35 +00:00

332 lines
7.5 KiB
Go

// Package process manages the agent subprocess lifecycle
package process
import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
"os/exec"
"sync"
"sync/atomic"
"time"
"github.com/coder/acp-go-sdk"
acpclient "github.com/kandev/kandev/internal/agentctl/acp"
"github.com/kandev/kandev/internal/agentctl/config"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Compile-time check that we export our client
var _ acp.Client = (*acpclient.Client)(nil)
// 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
}
// 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
// Output buffer for streaming
outputBuffer *OutputBuffer
// ACP SDK connection
acpClient *acpclient.Client
acpConn *acp.ClientSideConnection
sessionID acp.SessionId
// Session update notifications
updatesCh chan acp.SessionNotification
// 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")),
outputBuffer: NewOutputBuffer(cfg.OutputBufferSize),
updatesCh: make(chan acp.SessionNotification, 100),
}
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
}
// GetOutputBuffer returns the output buffer for streaming
func (m *Manager) GetOutputBuffer() *OutputBuffer {
return m.outputBuffer
}
// 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.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
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 ACP client that will handle agent requests
m.acpClient = acpclient.NewClient(
acpclient.WithLogger(m.logger.Zap()),
acpclient.WithWorkspaceRoot(m.cfg.WorkDir),
acpclient.WithUpdateHandler(func(n acp.SessionNotification) {
select {
case m.updatesCh <- n:
default:
m.logger.Warn("updates channel full, dropping notification")
}
}),
)
// Create ACP SDK connection - it handles JSON-RPC communication
m.acpConn = acp.NewClientSideConnection(m.acpClient, m.stdin, m.stdout)
m.acpConn.SetLogger(slog.Default().With("component", "acp-conn"))
// Start stderr reader and exit waiter
m.wg.Add(2)
go m.readStderr()
go m.waitForExit()
m.status.Store(StatusRunning)
m.logger.Info("agent process started", zap.Int("pid", m.cmd.Process.Pid))
return nil
}
// GetUpdates returns the channel for session update notifications
func (m *Manager) GetUpdates() <-chan acp.SessionNotification {
return m.updatesCh
}
// GetConnection returns the ACP connection (for advanced usage)
func (m *Manager) GetConnection() *acp.ClientSideConnection {
m.mu.RLock()
defer m.mu.RUnlock()
return m.acpConn
}
// GetSessionID returns the current session ID
func (m *Manager) GetSessionID() acp.SessionId {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessionID
}
// SetSessionID sets the current session ID
func (m *Manager) SetSessionID(id acp.SessionId) {
m.mu.Lock()
defer m.mu.Unlock()
m.sessionID = id
}
// 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)
// Close stop channel to signal readers
if m.stopCh != nil {
close(m.stopCh)
}
// Close stdin to signal EOF to agent
if m.stdin != nil {
m.stdin.Close()
}
// 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")
m.cmd.Process.Kill()
}
}
m.status.Store(StatusStopped)
return nil
}
// readStderr reads stderr from the agent
func (m *Manager) readStderr() {
defer m.wg.Done()
scanner := bufio.NewScanner(m.stderr)
for scanner.Scan() {
line := scanner.Text()
m.outputBuffer.Add(OutputLine{
Timestamp: time.Now(),
Stream: "stderr",
Content: 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
}