feat(backend): auto-launch agentctl subprocess in standalone mode (#12)
This commit is contained in:
@@ -5,6 +5,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
|
||||
// Agent Manager packages
|
||||
agentcontroller "github.com/kandev/kandev/internal/agent/controller"
|
||||
"github.com/kandev/kandev/internal/agent/agentctl/launcher"
|
||||
"github.com/kandev/kandev/internal/agent/credentials"
|
||||
"github.com/kandev/kandev/internal/agent/discovery"
|
||||
"github.com/kandev/kandev/internal/agent/docker"
|
||||
@@ -61,7 +63,42 @@ import (
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
// Command-line flags
|
||||
var (
|
||||
flagAgentRuntime = flag.String("agent-runtime", "", "Agent runtime mode: 'docker' or 'standalone'")
|
||||
flagPort = flag.Int("port", 0, "HTTP server port (default: 8080)")
|
||||
flagLogLevel = flag.String("log-level", "", "Log level: debug, info, warn, error")
|
||||
flagConfigPath = flag.String("config", "", "Path to config file")
|
||||
flagHelp = flag.Bool("help", false, "Show help message")
|
||||
flagVersion = flag.Bool("version", false, "Show version information")
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: kandev [options]\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Kandev is an AI-powered development task orchestrator.\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " kandev # Start with Docker runtime (default)\n")
|
||||
fmt.Fprintf(os.Stderr, " kandev -agent-runtime=standalone # Start with standalone agentctl\n")
|
||||
fmt.Fprintf(os.Stderr, " kandev -port=9000 -log-level=debug # Custom port and log level\n")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *flagHelp {
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *flagVersion {
|
||||
fmt.Println("kandev version 0.1.0")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 1. Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -69,6 +106,17 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Apply command-line flag overrides (flags take precedence over config/env)
|
||||
if *flagAgentRuntime != "" {
|
||||
cfg.Agent.Runtime = *flagAgentRuntime
|
||||
}
|
||||
if *flagPort > 0 {
|
||||
cfg.Server.Port = *flagPort
|
||||
}
|
||||
if *flagLogLevel != "" {
|
||||
cfg.Logging.Level = *flagLogLevel
|
||||
}
|
||||
|
||||
// 2. Initialize logger
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{
|
||||
Level: cfg.Logging.Level,
|
||||
@@ -174,14 +222,56 @@ func main() {
|
||||
// The comment system provides unified storage for all task-related communication
|
||||
log.Info("ACP messages will be stored as comments")
|
||||
|
||||
// ============================================
|
||||
// AGENTCTL LAUNCHER (for standalone mode)
|
||||
// ============================================
|
||||
var agentctlLauncher *launcher.Launcher
|
||||
|
||||
if cfg.Agent.Runtime == "standalone" {
|
||||
log.Info("Starting agentctl for standalone mode...")
|
||||
|
||||
agentctlLauncher = launcher.New(launcher.Config{
|
||||
Host: cfg.Agent.StandaloneHost,
|
||||
Port: cfg.Agent.StandalonePort,
|
||||
AutoApprovePermissions: true, // Always auto-approve in standalone mode (reindex, etc.)
|
||||
}, log)
|
||||
|
||||
if err := agentctlLauncher.Start(ctx); err != nil {
|
||||
log.Fatal("Failed to start agentctl subprocess", zap.Error(err))
|
||||
}
|
||||
|
||||
log.Info("agentctl started successfully",
|
||||
zap.Int("port", cfg.Agent.StandalonePort))
|
||||
|
||||
// Ensure agentctl is stopped on panic (in addition to Pdeathsig for crashes)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("panic recovered, stopping agentctl", zap.Any("panic", r))
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stopCancel()
|
||||
if err := agentctlLauncher.Stop(stopCtx); err != nil {
|
||||
log.Error("failed to stop agentctl on panic", zap.Error(err))
|
||||
}
|
||||
panic(r) // Re-panic after cleanup
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AGENT MANAGER
|
||||
// ============================================
|
||||
var lifecycleMgr *lifecycle.Manager
|
||||
var agentRegistry *registry.Registry
|
||||
|
||||
if dockerClient != nil {
|
||||
log.Info("Initializing Agent Manager...")
|
||||
// Agent manager is enabled if:
|
||||
// - Docker mode and Docker is available, OR
|
||||
// - Standalone mode (agentctl was started above)
|
||||
agentManagerEnabled := (cfg.Agent.Runtime == "docker" && dockerClient != nil) ||
|
||||
cfg.Agent.Runtime == "standalone"
|
||||
|
||||
if agentManagerEnabled {
|
||||
log.Info("Initializing Agent Manager...",
|
||||
zap.String("runtime", cfg.Agent.Runtime))
|
||||
|
||||
// Agent Registry
|
||||
agentRegistry = registry.NewRegistry(log)
|
||||
@@ -203,9 +293,11 @@ func main() {
|
||||
log.Fatal("Failed to start lifecycle manager", zap.Error(err))
|
||||
}
|
||||
|
||||
log.Info("Agent Manager initialized", zap.Int("agent_types", len(agentRegistry.List())))
|
||||
log.Info("Agent Manager initialized",
|
||||
zap.String("runtime", cfg.Agent.Runtime),
|
||||
zap.Int("agent_types", len(agentRegistry.List())))
|
||||
} else {
|
||||
log.Info("Agent Manager disabled (no Docker)")
|
||||
log.Info("Agent Manager disabled (no Docker and not in standalone mode)")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -590,8 +682,10 @@ func main() {
|
||||
// GRACEFUL SHUTDOWN
|
||||
// ============================================
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
sig := <-quit
|
||||
|
||||
log.Info("Received shutdown signal", zap.String("signal", sig.String()))
|
||||
|
||||
log.Info("Shutting down Kandev...")
|
||||
cancel()
|
||||
@@ -613,6 +707,14 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop agentctl subprocess (must be after lifecycle manager to allow cleanup)
|
||||
if agentctlLauncher != nil {
|
||||
log.Info("Stopping agentctl subprocess...")
|
||||
if err := agentctlLauncher.Stop(shutdownCtx); err != nil {
|
||||
log.Error("agentctl stop error", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Kandev stopped")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// 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("--control-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: kernel sends SIGTERM to child when parent dies (crash protection)
|
||||
// - Setpgid: create new process group so Ctrl+C doesn't propagate directly
|
||||
// (we handle shutdown ourselves via Stop())
|
||||
l.cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Pdeathsig: syscall.SIGTERM,
|
||||
Setpgid: true, // Don't inherit parent's process group
|
||||
}
|
||||
|
||||
// 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
|
||||
l.cmd.Process.Kill()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the agentctl process exits.
|
||||
// Returns the exit error if any.
|
||||
func (l *Launcher) Wait() error {
|
||||
<-l.exited
|
||||
if l.cmd != nil && l.cmd.ProcessState != nil {
|
||||
if !l.cmd.ProcessState.Success() {
|
||||
return fmt.Errorf("agentctl exited with code %d", l.cmd.ProcessState.ExitCode())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Running returns true if agentctl is currently running.
|
||||
func (l *Launcher) Running() bool {
|
||||
select {
|
||||
case <-l.exited:
|
||||
return false
|
||||
default:
|
||||
return l.cmd != nil && l.cmd.Process != nil
|
||||
}
|
||||
}
|
||||
|
||||
// Port returns the control port agentctl is listening on.
|
||||
func (l *Launcher) Port() int {
|
||||
return l.port
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
ln.Close()
|
||||
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 {
|
||||
resp.Body.Close()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export type AppConfig = {
|
||||
apiBaseUrl: string;
|
||||
};
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'http://localhost:8080';
|
||||
const DEFAULT_API_BASE_URL = 'http://nova:8080';
|
||||
|
||||
export function getBackendConfig(): AppConfig {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
Reference in New Issue
Block a user