From 2c1afd7701a1395e81bd3e472438b9bf6230671e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 13 Jan 2026 23:56:10 +0000 Subject: [PATCH] feat(backend): auto-launch agentctl subprocess in standalone mode (#12) --- apps/backend/cmd/kandev/main.go | 114 +++++- .../agent/agentctl/launcher/launcher.go | 335 ++++++++++++++++++ apps/web/lib/config.ts | 2 +- 3 files changed, 444 insertions(+), 7 deletions(-) create mode 100644 apps/backend/internal/agent/agentctl/launcher/launcher.go diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index b42db0d862..efc6a35545 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -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") } diff --git a/apps/backend/internal/agent/agentctl/launcher/launcher.go b/apps/backend/internal/agent/agentctl/launcher/launcher.go new file mode 100644 index 0000000000..31d760b50c --- /dev/null +++ b/apps/backend/internal/agent/agentctl/launcher/launcher.go @@ -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) +} + diff --git a/apps/web/lib/config.ts b/apps/web/lib/config.ts index 03246ccd93..67abe8b2a0 100644 --- a/apps/web/lib/config.ts +++ b/apps/web/lib/config.ts @@ -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') {