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

312 lines
8.1 KiB
Go

// Package launcher provides functionality to spawn and manage agentctl as a subprocess.
// This is used in standalone mode when kandev wants to manage the agentctl lifecycle
// rather than requiring the user to start it separately.
package launcher
import (
"bufio"
"context"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Launcher manages an agentctl subprocess.
type Launcher struct {
binaryPath string
host string
port int
autoApprovePermissions bool
logger *logger.Logger
cmd *exec.Cmd
exited chan struct{}
mu sync.Mutex
// For clean shutdown
stopping bool
}
// Config holds configuration for the launcher.
type Config struct {
BinaryPath string // Path to agentctl binary (auto-detected if empty)
Host string // Host to bind to (default: localhost)
Port int // Control port (default: 9999)
AutoApprovePermissions bool // Auto-approve permission requests (default: true for standalone)
}
// New creates a new Launcher.
func New(cfg Config, log *logger.Logger) *Launcher {
if cfg.Host == "" {
cfg.Host = "localhost"
}
if cfg.Port == 0 {
cfg.Port = 9999
}
if cfg.BinaryPath == "" {
cfg.BinaryPath = findAgentctlBinary()
}
return &Launcher{
binaryPath: cfg.BinaryPath,
host: cfg.Host,
port: cfg.Port,
autoApprovePermissions: cfg.AutoApprovePermissions,
logger: log.WithFields(zap.String("component", "agentctl-launcher")),
exited: make(chan struct{}),
}
}
// findAgentctlBinary attempts to locate the agentctl binary.
func findAgentctlBinary() string {
// 1. Check same directory as current executable
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), "agentctl")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 2. Check PATH
if path, err := exec.LookPath("agentctl"); err == nil {
return path
}
// 3. Check common development locations
candidates := []string{
"./bin/agentctl",
"./agentctl",
"../agentctl",
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
if abs, err := filepath.Abs(candidate); err == nil {
return abs
}
return candidate
}
}
return "agentctl" // Fall back to PATH lookup at runtime
}
// Start spawns the agentctl subprocess and waits for it to become healthy.
func (l *Launcher) Start(ctx context.Context) error {
l.mu.Lock()
defer l.mu.Unlock()
if l.cmd != nil {
return fmt.Errorf("agentctl already running")
}
// Check if port is available
if err := l.checkPortAvailable(); err != nil {
return fmt.Errorf("port %d not available: %w", l.port, err)
}
l.logger.Info("starting agentctl subprocess",
zap.String("binary", l.binaryPath),
zap.Int("port", l.port),
zap.Bool("auto_approve_permissions", l.autoApprovePermissions))
// Build command with standalone mode flags
// Note: We use exec.Command (not CommandContext) because we want to control
// shutdown ourselves via Stop(). CommandContext sends SIGKILL on context
// cancellation which prevents graceful shutdown.
l.cmd = exec.Command(l.binaryPath,
"-mode=standalone",
fmt.Sprintf("-port=%d", l.port),
)
// Set environment variables (inherit from parent + add agentctl-specific ones)
l.cmd.Env = append(os.Environ(),
fmt.Sprintf("AGENTCTL_AUTO_APPROVE_PERMISSIONS=%t", l.autoApprovePermissions),
)
// Set process attributes:
// - Pdeathsig on Linux: kernel sends SIGTERM to child when parent dies.
// - Setpgid: create new process group so Ctrl+C doesn't propagate directly.
l.cmd.SysProcAttr = buildSysProcAttr()
// Capture stdout and stderr
stdout, err := l.cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderr, err := l.cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Start the process
if err := l.cmd.Start(); err != nil {
return fmt.Errorf("failed to start agentctl: %w", err)
}
l.logger.Info("agentctl process started", zap.Int("pid", l.cmd.Process.Pid))
// Pipe stdout/stderr to logger in background
go l.pipeOutput("stdout", bufio.NewScanner(stdout))
go l.pipeOutput("stderr", bufio.NewScanner(stderr))
// Monitor process exit in background
go l.monitorExit()
// Wait for health check to pass
if err := l.waitForHealthy(ctx); err != nil {
// Kill the process if health check fails
if killErr := l.cmd.Process.Kill(); killErr != nil {
l.logger.Warn("failed to kill agentctl process after failed health check", zap.Error(killErr))
}
return fmt.Errorf("agentctl failed to become healthy: %w", err)
}
l.logger.Info("agentctl is healthy and ready")
return nil
}
// Stop gracefully shuts down the agentctl subprocess.
func (l *Launcher) Stop(ctx context.Context) error {
l.mu.Lock()
if l.cmd == nil || l.cmd.Process == nil {
l.mu.Unlock()
return nil
}
// Check if already exited
select {
case <-l.exited:
l.mu.Unlock()
l.logger.Info("agentctl already stopped")
return nil
default:
}
l.stopping = true
pid := l.cmd.Process.Pid
l.mu.Unlock()
l.logger.Info("stopping agentctl subprocess", zap.Int("pid", pid))
// Send SIGTERM for graceful shutdown
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
l.logger.Warn("failed to send SIGTERM, trying SIGKILL", zap.Error(err))
_ = syscall.Kill(pid, syscall.SIGKILL)
return err
}
// Wait for process to exit or context timeout
select {
case <-l.exited:
l.logger.Info("agentctl stopped gracefully")
return nil
case <-ctx.Done():
l.logger.Warn("graceful shutdown timed out, sending SIGKILL")
_ = syscall.Kill(pid, syscall.SIGKILL)
// Wait a bit for the kill to take effect
select {
case <-l.exited:
return nil
case <-time.After(2 * time.Second):
return fmt.Errorf("agentctl did not exit after SIGKILL")
}
}
}
// checkPortAvailable verifies the port is not in use.
func (l *Launcher) checkPortAvailable() error {
addr := fmt.Sprintf("%s:%d", l.host, l.port)
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
if err := ln.Close(); err != nil {
return err
}
return nil
}
// waitForHealthy polls the health endpoint until it succeeds or times out.
func (l *Launcher) waitForHealthy(ctx context.Context) error {
healthURL := fmt.Sprintf("http://%s:%d/health", l.host, l.port)
client := &http.Client{Timeout: 2 * time.Second}
// Use exponential backoff: 100ms, 200ms, 400ms, 800ms, 1s, 1s, ...
backoff := 100 * time.Millisecond
maxBackoff := 1 * time.Second
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
case <-l.exited:
return fmt.Errorf("agentctl exited unexpectedly during startup")
default:
}
resp, err := client.Get(healthURL)
if err == nil {
if closeErr := resp.Body.Close(); closeErr != nil {
l.logger.Debug("failed to close health response body", zap.Error(closeErr))
}
if resp.StatusCode == http.StatusOK {
return nil
}
}
l.logger.Debug("waiting for agentctl to be healthy",
zap.Duration("backoff", backoff),
zap.Error(err))
time.Sleep(backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
return fmt.Errorf("timeout waiting for agentctl to become healthy")
}
// pipeOutput reads from a scanner and logs each line.
func (l *Launcher) pipeOutput(name string, scanner *bufio.Scanner) {
for scanner.Scan() {
line := scanner.Text()
// Log at info level with agentctl prefix
l.logger.Info(line, zap.String("stream", name))
}
}
// monitorExit waits for the process to exit and signals via the exited channel.
func (l *Launcher) monitorExit() {
err := l.cmd.Wait()
l.mu.Lock()
stopping := l.stopping
l.mu.Unlock()
if err != nil && !stopping {
l.logger.Error("agentctl exited unexpectedly",
zap.Error(err),
zap.Int("exit_code", l.cmd.ProcessState.ExitCode()))
} else if !stopping {
l.logger.Info("agentctl exited",
zap.Int("exit_code", l.cmd.ProcessState.ExitCode()))
}
close(l.exited)
}