feat: per-task executor selection with multi-runtime support (#92)

This commit is contained in:
João Salavisa
2026-01-25 02:05:19 +00:00
committed by GitHub
parent d43037bf82
commit 187d002717
32 changed files with 1549 additions and 238 deletions
+1
View File
@@ -72,6 +72,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
ACPSessionID: req.ACPSessionID,
Metadata: req.Metadata,
ModelOverride: req.ModelOverride,
ExecutorType: req.ExecutorType,
// Worktree configuration for concurrent agent execution
UseWorktree: req.UseWorktree,
RepositoryID: req.RepositoryID,
+5 -6
View File
@@ -11,10 +11,10 @@ import (
"go.uber.org/zap"
)
// provideAgentctlLauncher starts the agentctl launcher for standalone runtime.
// agentctl is a core service that always runs - it's used by the Standalone runtime
// for agent execution on the host machine.
func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logger.Logger) (func() error, error) {
if cfg.Agent.Runtime != "standalone" {
return nil, nil
}
_, cleanup, err := launcher.Provide(ctx, launcher.Config{
Host: cfg.Agent.StandaloneHost,
Port: cfg.Agent.StandalonePort,
@@ -25,10 +25,9 @@ func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logge
return cleanup, nil
}
// waitForAgentctlControlHealthy waits for the agentctl control server to be healthy.
// This is called during startup to ensure agentctl is ready before accepting requests.
func waitForAgentctlControlHealthy(ctx context.Context, cfg *config.Config, log *logger.Logger) {
if cfg.Agent.Runtime != "standalone" {
return
}
client := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log)
healthCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
+44 -36
View File
@@ -26,14 +26,38 @@ func provideLifecycleManager(
dockerClient *docker.Client,
agentSettingsRepo settingsstore.Repository,
) (*lifecycle.Manager, *registry.Registry, error) {
agentManagerEnabled := (cfg.Agent.Runtime == "docker" && dockerClient != nil) ||
cfg.Agent.Runtime == "standalone"
if !agentManagerEnabled {
log.Info("Agent Manager disabled (no Docker and not in standalone mode)")
return nil, nil, nil
}
log.Info("Initializing Agent Manager...")
log.Info("Initializing Agent Manager...", zap.String("runtime", cfg.Agent.Runtime))
// Create runtime registry to manage multiple runtimes
runtimeRegistry := lifecycle.NewRuntimeRegistry(log)
// Standalone runtime is always available (agentctl is a core service)
controlClient := agentctl.NewControlClient(
cfg.Agent.StandaloneHost,
cfg.Agent.StandalonePort,
log,
)
standaloneRuntime := lifecycle.NewStandaloneRuntime(
controlClient,
cfg.Agent.StandaloneHost,
cfg.Agent.StandalonePort,
log,
)
runtimeRegistry.Register(standaloneRuntime)
log.Info("Standalone runtime registered",
zap.String("host", cfg.Agent.StandaloneHost),
zap.Int("port", cfg.Agent.StandalonePort))
// Register Docker runtime if enabled and Docker client is available
var containerMgr *lifecycle.ContainerManager
if cfg.Docker.Enabled && dockerClient != nil {
containerMgr = lifecycle.NewContainerManager(dockerClient, "", log)
dockerRuntime := lifecycle.NewDockerRuntime(dockerClient, log)
runtimeRegistry.Register(dockerRuntime)
log.Info("Docker runtime registered")
} else if cfg.Docker.Enabled && dockerClient == nil {
log.Warn("Docker runtime enabled but Docker client not available")
}
agentRegistry, _, err := registry.Provide(log)
if err != nil {
@@ -48,41 +72,25 @@ func provideLifecycleManager(
}
profileResolver := lifecycle.NewStoreProfileResolver(agentSettingsRepo, agentRegistry)
var agentRuntime lifecycle.Runtime
var containerMgr *lifecycle.ContainerManager
switch cfg.Agent.Runtime {
case "standalone":
controlClient := agentctl.NewControlClient(
cfg.Agent.StandaloneHost,
cfg.Agent.StandalonePort,
log,
)
agentRuntime = lifecycle.NewStandaloneRuntime(
controlClient,
cfg.Agent.StandaloneHost,
cfg.Agent.StandalonePort,
log,
)
log.Info("Using standalone runtime",
zap.String("host", cfg.Agent.StandaloneHost),
zap.Int("port", cfg.Agent.StandalonePort))
default:
if dockerClient != nil {
containerMgr = lifecycle.NewContainerManager(dockerClient, "", log)
agentRuntime = lifecycle.NewDockerRuntime(dockerClient, log)
log.Info("Using Docker runtime")
}
}
mcpService := mcpconfig.NewService(agentSettingsRepo)
lifecycleMgr := lifecycle.NewManager(agentRegistry, eventBus, agentRuntime, containerMgr, credsMgr, profileResolver, mcpService, log)
lifecycleMgr := lifecycle.NewManager(
agentRegistry,
eventBus,
runtimeRegistry,
containerMgr,
credsMgr,
profileResolver,
mcpService,
lifecycle.RuntimeFallbackWarn,
log,
)
if err := lifecycleMgr.Start(ctx); err != nil {
return nil, nil, err
}
log.Info("Agent Manager initialized",
zap.String("runtime", cfg.Agent.Runtime),
zap.Int("runtimes", len(runtimeRegistry.List())),
zap.Int("agent_types", len(agentRegistry.List())))
return lifecycleMgr, agentRegistry, nil
}
+5 -10
View File
@@ -52,11 +52,10 @@ import (
// 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")
flagHelp = flag.Bool("help", false, "Show help message")
flagVersion = flag.Bool("version", false, "Show version information")
flagPort = flag.Int("port", 0, "HTTP server port (default: 8080)")
flagLogLevel = flag.String("log-level", "", "Log level: debug, info, warn, error")
flagHelp = flag.Bool("help", false, "Show help message")
flagVersion = flag.Bool("version", false, "Show version information")
)
func init() {
@@ -66,8 +65,7 @@ func init() {
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 # Start with default settings\n")
fmt.Fprintf(os.Stderr, " kandev -port=9000 -log-level=debug # Custom port and log level\n")
}
}
@@ -93,9 +91,6 @@ func main() {
}
// Apply command-line flag overrides (flags take precedence over config/env)
if *flagAgentRuntime != "" {
cfg.Agent.Runtime = *flagAgentRuntime
}
if *flagPort > 0 {
cfg.Server.Port = *flagPort
}
@@ -1,5 +1,5 @@
# Augment Agent Dockerfile
# A coding agent powered by Auggie CLI (Augment Code) with agentctl sidecar
# Kandev Multi-Agent Dockerfile
# A multi-agent image supporting Auggie CLI (Augment Code) and Codex CLI (OpenAI) with agentctl sidecar
#
# Prerequisites: Build agentctl before building this image:
# make build-agentctl
@@ -19,6 +19,9 @@ RUN apt-get update && apt-get install -y \
# Install Auggie CLI from npm
RUN npm install -g @augmentcode/auggie
# Install Codex CLI from npm
RUN npm install -g @openai/codex
# Copy pre-built agentctl binary (run `make build-agentctl` first)
COPY bin/agentctl /usr/local/bin/agentctl
RUN chmod +x /usr/local/bin/agentctl
@@ -27,7 +30,7 @@ RUN chmod +x /usr/local/bin/agentctl
WORKDIR /agent
# Copy entrypoint script
COPY dockerfiles/augment-agent/entrypoint.sh /agent/entrypoint.sh
COPY dockerfiles/entrypoint.sh /agent/entrypoint.sh
RUN chmod +x /agent/entrypoint.sh
# Workspace will be mounted here
@@ -38,18 +41,22 @@ WORKDIR /workspace
# TASK_ID - The task identifier
# TASK_TITLE - Task title/name
# TASK_DESCRIPTION - Full task description
#
# For Auggie:
# AUGMENT_SESSION_AUTH - Session auth JSON for Augment (from ~/.augment/session.json)
# AUGGIE_SESSION_ID - (Optional) Session ID to resume a previous conversation
#
# For Codex:
# OPENAI_API_KEY - OpenAI API key for Codex
#
# agentctl configuration:
# AGENTCTL_PORT - HTTP API port (default: 9999)
# AGENTCTL_AGENT_COMMAND - Command to run (default: "auggie --acp")
# AGENTCTL_AGENT_COMMAND - Command to run (e.g., "auggie --acp" or "codex app-server")
# AGENTCTL_AUTO_START - Auto-start agent on startup (default: false)
# AGENTCTL_MODE - "agentctl" for HTTP mode, "direct" for legacy stdin/stdout mode
# Expose agentctl HTTP API port
EXPOSE 9999
# Default entrypoint that decides between agentctl and direct mode
# Run agentctl HTTP server
ENTRYPOINT ["/agent/entrypoint.sh"]
@@ -1,41 +0,0 @@
#!/bin/bash
# Entrypoint script for Augment Agent
# Supports two modes:
# - "agentctl": Runs agentctl HTTP server that manages auggie subprocess
# - "direct": Legacy mode that runs auggie directly (stdin/stdout)
set -e
MODE="${AGENTCTL_MODE:-agentctl}"
echo "Starting Augment Agent in ${MODE} mode..."
# Common validation
if ! command -v auggie &> /dev/null; then
echo "ERROR: auggie CLI not found. Is @augmentcode/auggie installed?"
exit 1
fi
if [ -z "$AUGMENT_SESSION_AUTH" ]; then
echo "ERROR: AUGMENT_SESSION_AUTH environment variable not set"
exit 1
fi
case "$MODE" in
"agentctl")
# Run agentctl HTTP server
echo "Starting agentctl on port ${AGENTCTL_PORT:-9999}..."
exec /usr/local/bin/agentctl
;;
"direct")
# Legacy mode: run auggie directly
echo "Running auggie directly in ACP mode..."
exec auggie --acp --workspace-root /workspace
;;
*)
echo "ERROR: Unknown AGENTCTL_MODE: $MODE"
echo "Valid modes: agentctl, direct"
exit 1
;;
esac
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Entrypoint script for Kandev Multi-Agent
# Runs agentctl HTTP server that manages agent subprocesses (auggie, codex, etc.)
set -e
echo "Starting Kandev Multi-Agent..."
# Validate agent CLIs are available
if ! command -v auggie &> /dev/null; then
echo "WARNING: auggie CLI not found"
fi
if ! command -v codex &> /dev/null; then
echo "WARNING: codex CLI not found"
fi
# Run agentctl HTTP server
echo "Starting agentctl on port ${AGENTCTL_PORT:-9999}..."
exec /usr/local/bin/agentctl
@@ -183,7 +183,7 @@ func newTestManager() *lifecycle.Manager {
credsMgr := &MockCredentialsManager{}
profileResolver := &MockProfileResolver{}
// Pass nil for runtime and containerManager - tests don't need them
return lifecycle.NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, log)
return lifecycle.NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, lifecycle.RuntimeFallbackWarn, log)
}
func TestWsShellStatus_NoInstanceFound(t *testing.T) {
@@ -5,6 +5,7 @@ package lifecycle
import (
"context"
"fmt"
"os"
"strings"
"time"
@@ -277,6 +278,16 @@ func (cm *ContainerManager) expandMounts(templates []registry.MountTemplate, wor
func (cm *ContainerManager) expandMountSource(source, workspacePath string) string {
result := source
result = strings.ReplaceAll(result, "{workspace}", workspacePath)
// Expand {home} to user's home directory
if strings.Contains(result, "{home}") {
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "/tmp"
}
result = strings.ReplaceAll(result, "{home}", homeDir)
}
return result
}
+178 -83
View File
@@ -17,7 +17,6 @@ import (
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/runtime"
"github.com/kandev/kandev/internal/worktree"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
@@ -25,9 +24,23 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// RuntimeFallbackPolicy controls behavior when a requested runtime is unavailable.
type RuntimeFallbackPolicy string
const (
// RuntimeFallbackAllow silently falls back to the default runtime (current behavior).
RuntimeFallbackAllow RuntimeFallbackPolicy = "allow"
// RuntimeFallbackWarn falls back but logs a warning (current behavior, explicit).
RuntimeFallbackWarn RuntimeFallbackPolicy = "warn"
// RuntimeFallbackDeny returns an error if the requested runtime is unavailable.
RuntimeFallbackDeny RuntimeFallbackPolicy = "deny"
)
// Manager manages agent instance lifecycles
type Manager struct {
registry *registry.Registry
@@ -38,17 +51,21 @@ type Manager struct {
mcpProvider McpConfigProvider
logger *logger.Logger
// Agent runtime abstraction (Docker, Standalone, K8s, SSH, etc.)
runtime Runtime
// RuntimeRegistry manages multiple runtimes (Docker, Standalone, etc.)
// Each task can select its runtime based on executor type.
runtimeRegistry *RuntimeRegistry
// runtimeFallbackPolicy controls behavior when a requested runtime is unavailable.
runtimeFallbackPolicy RuntimeFallbackPolicy
// Refactored components for separation of concerns
executionStore *ExecutionStore // Thread-safe execution tracking
commandBuilder *CommandBuilder // Builds agent commands from registry config
sessionManager *SessionManager // Handles ACP session initialization
streamManager *StreamManager // Manages WebSocket streams
eventPublisher *EventPublisher // Publishes lifecycle events
containerManager *ContainerManager // Manages Docker containers (optional, nil for non-Docker runtimes)
historyManager *SessionHistoryManager // Stores session history for context injection (fork_session pattern)
executionStore *ExecutionStore // Thread-safe execution tracking
commandBuilder *CommandBuilder // Builds agent commands from registry config
sessionManager *SessionManager // Handles ACP session initialization
streamManager *StreamManager // Manages WebSocket streams
eventPublisher *EventPublisher // Publishes lifecycle events
containerManager *ContainerManager // Manages Docker containers (optional, nil for non-Docker runtimes)
historyManager *SessionHistoryManager // Stores session history for context injection (fork_session pattern)
// Workspace info provider for on-demand instance creation
workspaceInfoProvider WorkspaceInfoProvider
@@ -60,16 +77,18 @@ type Manager struct {
}
// NewManager creates a new lifecycle manager.
// The runtime parameter is the agent execution runtime (Docker, Standalone, etc.).
// The runtimeRegistry manages multiple runtimes (Docker, Standalone, etc.) for task-specific execution.
// The containerManager parameter is optional and used for Docker cleanup (pass nil for non-Docker runtimes).
// The fallbackPolicy controls behavior when a requested runtime is unavailable.
func NewManager(
reg *registry.Registry,
eventBus bus.EventBus,
runtime Runtime,
runtimeRegistry *RuntimeRegistry,
containerManager *ContainerManager,
credsMgr CredentialsManager,
profileResolver ProfileResolver,
mcpProvider McpConfigProvider,
fallbackPolicy RuntimeFallbackPolicy,
log *logger.Logger,
) *Manager {
componentLogger := log.WithFields(zap.String("component", "lifecycle-manager"))
@@ -96,21 +115,22 @@ func NewManager(
}
mgr := &Manager{
registry: reg,
eventBus: eventBus,
runtime: runtime,
credsMgr: credsMgr,
profileResolver: profileResolver,
mcpProvider: mcpProvider,
logger: componentLogger,
executionStore: executionStore,
commandBuilder: commandBuilder,
sessionManager: sessionManager,
eventPublisher: eventPublisher,
containerManager: containerManager,
historyManager: historyManager,
cleanupInterval: 30 * time.Second,
stopCh: stopCh,
registry: reg,
eventBus: eventBus,
runtimeRegistry: runtimeRegistry,
runtimeFallbackPolicy: fallbackPolicy,
credsMgr: credsMgr,
profileResolver: profileResolver,
mcpProvider: mcpProvider,
logger: componentLogger,
executionStore: executionStore,
commandBuilder: commandBuilder,
sessionManager: sessionManager,
eventPublisher: eventPublisher,
containerManager: containerManager,
historyManager: historyManager,
cleanupInterval: 30 * time.Second,
stopCh: stopCh,
}
// Initialize stream manager with callbacks that delegate to manager methods
@@ -129,8 +149,8 @@ func NewManager(
// Set session manager dependencies for full orchestration
sessionManager.SetDependencies(eventPublisher, mgr.streamManager, executionStore, historyManager)
if runtime != nil {
mgr.logger.Info("initialized with runtime", zap.String("runtime", string(runtime.Name())))
if runtimeRegistry != nil {
mgr.logger.Info("initialized with runtimes", zap.Int("count", len(runtimeRegistry.List())))
}
return mgr
@@ -165,6 +185,54 @@ func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) {
m.workspaceInfoProvider = provider
}
// getRuntimeForExecutorType returns the appropriate runtime for the given executor type.
// If the executor type is empty or the runtime is not available, behavior depends on runtimeFallbackPolicy.
func (m *Manager) getRuntimeForExecutorType(executorType string) (Runtime, error) {
if m.runtimeRegistry == nil {
return nil, fmt.Errorf("no runtime registry configured")
}
if executorType != "" {
runtimeName := runtime.ExecutorTypeToRuntime(models.ExecutorType(executorType))
rt, err := m.runtimeRegistry.GetRuntime(runtimeName)
if err == nil {
return rt, nil
}
// Handle fallback based on policy
switch m.runtimeFallbackPolicy {
case RuntimeFallbackDeny:
return nil, fmt.Errorf("runtime %s not available and fallback is denied: %w", runtimeName, err)
case RuntimeFallbackWarn:
m.logger.Warn("requested runtime not available, falling back to default",
zap.String("executor_type", executorType),
zap.String("runtime", string(runtimeName)),
zap.Error(err))
case RuntimeFallbackAllow:
m.logger.Debug("requested runtime not available, falling back to default",
zap.String("executor_type", executorType),
zap.String("runtime", string(runtimeName)))
default:
// Default to warn behavior for backwards compatibility
m.logger.Warn("requested runtime not available, falling back to default",
zap.String("executor_type", executorType),
zap.String("runtime", string(runtimeName)),
zap.Error(err))
}
}
return m.runtimeRegistry.GetRuntime(runtime.NameStandalone)
}
// getDefaultRuntime returns the default runtime (standalone).
// This is used when no executor type is specified.
func (m *Manager) getDefaultRuntime() (Runtime, error) {
if m.runtimeRegistry == nil {
return nil, fmt.Errorf("no runtime registry configured")
}
return m.runtimeRegistry.GetRuntime(runtime.NameStandalone)
}
// EnsureWorkspaceExecutionForSession ensures an agentctl execution exists for a specific task session.
// This is used when the frontend provides a session ID (e.g., from URL path /task/[id]/[sessionId]).
// If an execution already exists for the session, it returns it. Otherwise, it creates a new execution
@@ -228,8 +296,10 @@ func (m *Manager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID
// createExecution creates an agentctl execution.
// The agent subprocess is NOT started - call ConfigureAgent + Start explicitly.
func (m *Manager) createExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) {
if m.runtime == nil {
return nil, fmt.Errorf("no runtime configured")
// Get the default runtime for on-demand execution creation
rt, err := m.getDefaultRuntime()
if err != nil {
return nil, fmt.Errorf("no runtime configured: %w", err)
}
if info.AgentID == "" {
@@ -252,12 +322,13 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work
Protocol: string(agentConfig.Protocol),
}
runtimeInstance, err := m.runtime.CreateInstance(ctx, req)
runtimeInstance, err := rt.CreateInstance(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to create execution: %w", err)
}
execution := runtimeInstance.ToAgentExecution(req)
execution.RuntimeName = string(rt.Name())
// Set the ACP session ID for session resumption
if info.ACPSessionID != "" {
@@ -272,38 +343,38 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work
zap.String("execution_id", executionID),
zap.String("task_id", taskID),
zap.String("workspace_path", info.WorkspacePath),
zap.String("runtime", string(m.runtime.Name())))
zap.String("runtime", execution.RuntimeName))
return execution, nil
}
// Start starts the lifecycle manager background tasks
func (m *Manager) Start(ctx context.Context) error {
runtimeName := "none"
if m.runtime != nil {
runtimeName = string(m.runtime.Name())
}
m.logger.Info("starting lifecycle manager", zap.String("runtime", runtimeName))
if m.runtime == nil {
m.logger.Warn("no runtime configured")
if m.runtimeRegistry == nil {
m.logger.Warn("no runtime registry configured")
return nil
}
// Check runtime health
if err := m.runtime.HealthCheck(ctx); err != nil {
m.logger.Warn("runtime health check failed",
zap.String("runtime", runtimeName),
zap.Error(err))
// Continue anyway - it might come up later
} else {
m.logger.Info("runtime is healthy", zap.String("runtime", runtimeName))
runtimeNames := m.runtimeRegistry.List()
m.logger.Info("starting lifecycle manager", zap.Int("runtimes", len(runtimeNames)))
// Check health of all registered runtimes
healthResults := m.runtimeRegistry.HealthCheckAll(ctx)
for name, err := range healthResults {
if err != nil {
m.logger.Warn("runtime health check failed",
zap.String("runtime", string(name)),
zap.Error(err))
} else {
m.logger.Info("runtime is healthy", zap.String("runtime", string(name)))
}
}
// Try to recover executions from previous runs
recovered, err := m.runtime.RecoverInstances(ctx)
// Try to recover executions from all runtimes
recovered, err := m.runtimeRegistry.RecoverAll(ctx)
if err != nil {
m.logger.Warn("failed to recover executions", zap.Error(err))
} else if len(recovered) > 0 {
m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err))
}
if len(recovered) > 0 {
for _, ri := range recovered {
execution := &AgentExecution{
ID: ri.InstanceID,
@@ -312,6 +383,7 @@ func (m *Manager) Start(ctx context.Context) error {
ContainerID: ri.ContainerID,
ContainerIP: ri.ContainerIP,
WorkspacePath: ri.WorkspacePath,
RuntimeName: ri.RuntimeName,
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Metadata: ri.Metadata,
@@ -320,6 +392,10 @@ func (m *Manager) Start(ctx context.Context) error {
standalonePort: ri.StandalonePort,
}
m.executionStore.Add(execution)
// Reconnect to workspace streams (shell, git, file changes) in background
// This is needed so shell.input, git status, etc. work after backend restart
go m.streamManager.ReconnectAll(execution)
}
m.logger.Info("recovered executions", zap.Int("count", len(recovered)))
}
@@ -491,8 +567,10 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
// 7. Launch via runtime - creates agentctl instance with workspace access only
// Agent subprocess is NOT started - call StartAgentProcess() explicitly
if m.runtime == nil {
return nil, fmt.Errorf("no runtime configured")
// Select runtime based on executor type from request
rt, err := m.getRuntimeForExecutorType(req.ExecutorType)
if err != nil {
return nil, fmt.Errorf("no runtime configured: %w", err)
}
// Build environment variables
@@ -517,6 +595,23 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
})
}
// Build metadata with runtime-specific fields
metadata := make(map[string]interface{})
if reqWithWorktree.Metadata != nil {
for k, v := range reqWithWorktree.Metadata {
metadata[k] = v
}
}
if mainRepoGitDir != "" {
metadata[MetadataKeyMainRepoGitDir] = mainRepoGitDir
}
if worktreeID != "" {
metadata[MetadataKeyWorktreeID] = worktreeID
}
if worktreeBranch != "" {
metadata[MetadataKeyWorktreeBranch] = worktreeBranch
}
// Create runtime request (agent command not included - started explicitly later)
runtimeReq := &RuntimeCreateRequest{
InstanceID: executionID,
@@ -526,21 +621,19 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
WorkspacePath: reqWithWorktree.WorkspacePath,
Protocol: string(agentConfig.Protocol),
Env: env,
Metadata: reqWithWorktree.Metadata,
WorktreeID: worktreeID,
WorktreeBranch: worktreeBranch,
MainRepoGitDir: mainRepoGitDir,
Metadata: metadata,
AgentConfig: agentConfig,
McpServers: mcpServers,
}
runtimeInstance, err := m.runtime.CreateInstance(ctx, runtimeReq)
runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq)
if err != nil {
return nil, fmt.Errorf("failed to create execution: %w", err)
}
// Convert to AgentExecution
// Convert to AgentExecution and set the runtime name
execution := runtimeInstance.ToAgentExecution(runtimeReq)
execution.RuntimeName = string(rt.Name())
// Set ACP session ID for session resumption (used by InitializeSession)
if req.ACPSessionID != "" {
@@ -582,14 +675,10 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
// NOTE: This does NOT start the agent process - call StartAgentProcess() explicitly
go m.waitForAgentctlReady(execution)
runtimeName := "unknown"
if m.runtime != nil {
runtimeName = string(m.runtime.Name())
}
m.logger.Info("agentctl execution created (agent not started)",
zap.String("execution_id", executionID),
zap.String("task_id", req.TaskID),
zap.String("runtime", runtimeName))
zap.String("runtime", execution.RuntimeName))
return execution, nil
}
@@ -876,7 +965,9 @@ func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID str
return nil, nil
}
policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(m.runtime))
// Get default runtime for MCP policy (used before execution exists)
defaultRT, _ := m.getDefaultRuntime()
policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(defaultRT))
executorID := ""
if metadata != nil {
if value, ok := metadata["executor_id"].(string); ok {
@@ -1341,14 +1432,10 @@ func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool)
return fmt.Errorf("execution %q not found", executionID)
}
runtimeName := "unknown"
if m.runtime != nil {
runtimeName = string(m.runtime.Name())
}
m.logger.Info("stopping agent",
zap.String("execution_id", executionID),
zap.Bool("force", force),
zap.String("runtime", runtimeName))
zap.String("runtime", execution.RuntimeName))
// Try to gracefully stop via agentctl first
if execution.agentctl != nil && !force {
@@ -1360,17 +1447,25 @@ func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool)
execution.agentctl.Close()
}
// Stop the agent execution via runtime
if m.runtime != nil {
runtimeInstance := &RuntimeInstance{
InstanceID: execution.ID,
TaskID: execution.TaskID,
ContainerID: execution.ContainerID,
StandaloneInstanceID: execution.standaloneInstanceID,
StandalonePort: execution.standalonePort,
}
if err := m.runtime.StopInstance(ctx, runtimeInstance, force); err != nil {
return err
// Stop the agent execution via the runtime that created it
if execution.RuntimeName != "" && m.runtimeRegistry != nil {
rt, err := m.runtimeRegistry.GetRuntime(runtime.Name(execution.RuntimeName))
if err != nil {
m.logger.Warn("failed to get runtime for stopping execution",
zap.String("execution_id", executionID),
zap.String("runtime", execution.RuntimeName),
zap.Error(err))
} else {
runtimeInstance := &RuntimeInstance{
InstanceID: execution.ID,
TaskID: execution.TaskID,
ContainerID: execution.ContainerID,
StandaloneInstanceID: execution.standaloneInstanceID,
StandalonePort: execution.standalonePort,
}
if err := rt.StopInstance(ctx, runtimeInstance, force); err != nil {
return err
}
}
}
@@ -153,7 +153,7 @@ func newTestManager() *Manager {
credsMgr := &MockCredentialsManager{}
profileResolver := &MockProfileResolver{}
// Pass nil for runtime and containerManager - tests don't need them
return NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, log)
return NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, RuntimeFallbackWarn, log)
}
func TestNewManager(t *testing.T) {
@@ -43,6 +43,13 @@ type McpServerConfig struct {
Args []string `json:"args,omitempty"`
}
// Metadata keys for runtime-specific configuration
const (
MetadataKeyMainRepoGitDir = "main_repo_git_dir"
MetadataKeyWorktreeID = "worktree_id"
MetadataKeyWorktreeBranch = "worktree_branch"
)
// RuntimeCreateRequest contains parameters for creating an agentctl instance.
type RuntimeCreateRequest struct {
InstanceID string
@@ -54,11 +61,7 @@ type RuntimeCreateRequest struct {
Env map[string]string
Metadata map[string]interface{}
McpServers []McpServerConfig
// Docker-specific
AgentConfig *registry.AgentTypeConfig
MainRepoGitDir string
WorktreeID string
WorktreeBranch string
AgentConfig *registry.AgentTypeConfig // Agent type info needed by runtimes
}
// RuntimeInstance represents an agentctl instance created by a runtime.
@@ -69,6 +72,9 @@ type RuntimeInstance struct {
TaskID string
SessionID string
// Runtime name (e.g., "docker", "standalone") - set by the runtime that created this instance
RuntimeName string
// Agentctl client for communicating with this instance
Client *agentctl.Client
@@ -107,6 +113,7 @@ func (ri *RuntimeInstance) ToAgentExecution(req *RuntimeCreateRequest) *AgentExe
ContainerID: ri.ContainerID,
ContainerIP: ri.ContainerIP,
WorkspacePath: workspacePath,
RuntimeName: ri.RuntimeName,
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Metadata: metadata,
@@ -13,6 +13,17 @@ import (
"github.com/kandev/kandev/internal/common/logger"
)
// getMetadataString retrieves a string value from metadata map.
func getMetadataString(metadata map[string]interface{}, key string) string {
if metadata == nil {
return ""
}
if v, ok := metadata[key].(string); ok {
return v
}
return ""
}
// DockerRuntime implements Runtime for Docker-based agent execution.
type DockerRuntime struct {
containerMgr *ContainerManager
@@ -43,6 +54,11 @@ func (r *DockerRuntime) HealthCheck(ctx context.Context) error {
}
func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) {
// Extract runtime-specific values from metadata
mainRepoGitDir := getMetadataString(req.Metadata, MetadataKeyMainRepoGitDir)
worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID)
worktreeBranch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch)
// Convert RuntimeCreateRequest to ContainerConfig
containerCfg := ContainerConfig{
AgentConfig: req.AgentConfig,
@@ -50,7 +66,7 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe
TaskID: req.TaskID,
SessionID: req.SessionID,
InstanceID: req.InstanceID,
MainRepoGitDir: req.MainRepoGitDir,
MainRepoGitDir: mainRepoGitDir,
Credentials: req.Env, // Env contains credentials from the caller
McpServers: req.McpServers,
}
@@ -66,10 +82,10 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe
// Build metadata
metadata := make(map[string]interface{})
if req.WorktreeID != "" {
metadata["worktree_id"] = req.WorktreeID
if worktreeID != "" {
metadata["worktree_id"] = worktreeID
metadata["worktree_path"] = req.WorkspacePath
metadata["worktree_branch"] = req.WorktreeBranch
metadata["worktree_branch"] = worktreeBranch
}
r.logger.Info("docker instance created",
@@ -81,6 +97,7 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe
InstanceID: req.InstanceID,
TaskID: req.TaskID,
SessionID: req.SessionID,
RuntimeName: string(r.Name()),
Client: client,
ContainerID: containerID,
ContainerIP: containerIP,
@@ -173,6 +190,7 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc
InstanceID: instanceID,
TaskID: taskID,
SessionID: sessionID,
RuntimeName: string(r.Name()),
Client: client,
ContainerID: ctr.ID,
ContainerIP: containerIP,
@@ -0,0 +1,130 @@
// Package lifecycle provides agent runtime abstractions.
package lifecycle
import (
"context"
"fmt"
"sync"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/runtime"
"github.com/kandev/kandev/internal/common/logger"
)
// ErrRuntimeNotFound is returned when a runtime doesn't exist in the registry.
var ErrRuntimeNotFound = fmt.Errorf("runtime not found")
// RuntimeRegistry manages multiple Runtime implementations and provides
// thread-safe access to them. It supports registering runtimes by name,
// health checking, and instance recovery across all registered runtimes.
type RuntimeRegistry struct {
runtimes map[runtime.Name]Runtime
mu sync.RWMutex
logger *logger.Logger
}
// NewRuntimeRegistry creates a new RuntimeRegistry with the given logger.
func NewRuntimeRegistry(log *logger.Logger) *RuntimeRegistry {
return &RuntimeRegistry{
runtimes: make(map[runtime.Name]Runtime),
logger: log,
}
}
// Register adds a runtime to the registry using its Name() as the key.
// If a runtime with the same name already exists, it will be replaced.
func (r *RuntimeRegistry) Register(rt Runtime) {
r.mu.Lock()
defer r.mu.Unlock()
name := rt.Name()
r.runtimes[name] = rt
r.logger.Info("registered runtime", zap.String("name", string(name)))
}
// GetRuntime returns a runtime by its name.
// Returns ErrRuntimeNotFound if the runtime doesn't exist.
func (r *RuntimeRegistry) GetRuntime(name runtime.Name) (Runtime, error) {
r.mu.RLock()
defer r.mu.RUnlock()
rt, exists := r.runtimes[name]
if !exists {
return nil, fmt.Errorf("%w: %s", ErrRuntimeNotFound, name)
}
return rt, nil
}
// List returns the names of all registered runtimes.
func (r *RuntimeRegistry) List() []runtime.Name {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]runtime.Name, 0, len(r.runtimes))
for name := range r.runtimes {
names = append(names, name)
}
return names
}
// HealthCheckAll performs health checks on all registered runtimes.
// Returns a map of runtime names to errors. A nil error indicates the runtime is healthy.
func (r *RuntimeRegistry) HealthCheckAll(ctx context.Context) map[runtime.Name]error {
r.mu.RLock()
runtimes := make(map[runtime.Name]Runtime, len(r.runtimes))
for name, rt := range r.runtimes {
runtimes[name] = rt
}
r.mu.RUnlock()
results := make(map[runtime.Name]error, len(runtimes))
for name, rt := range runtimes {
err := rt.HealthCheck(ctx)
results[name] = err
if err != nil {
r.logger.Warn("runtime health check failed",
zap.String("runtime", string(name)),
zap.Error(err))
} else {
r.logger.Debug("runtime health check passed",
zap.String("runtime", string(name)))
}
}
return results
}
// RecoverAll recovers instances from all registered runtimes.
// Returns all recovered instances and any error encountered.
// If multiple runtimes fail, only the last error is returned.
func (r *RuntimeRegistry) RecoverAll(ctx context.Context) ([]*RuntimeInstance, error) {
r.mu.RLock()
runtimes := make(map[runtime.Name]Runtime, len(r.runtimes))
for name, rt := range r.runtimes {
runtimes[name] = rt
}
r.mu.RUnlock()
var allInstances []*RuntimeInstance
var lastErr error
for name, rt := range runtimes {
instances, err := rt.RecoverInstances(ctx)
if err != nil {
r.logger.Error("failed to recover instances from runtime",
zap.String("runtime", string(name)),
zap.Error(err))
lastErr = err
continue
}
if len(instances) > 0 {
r.logger.Info("recovered instances from runtime",
zap.String("runtime", string(name)),
zap.Int("count", len(instances)))
allInstances = append(allInstances, instances...)
}
}
return allInstances, lastErr
}
@@ -0,0 +1,347 @@
package lifecycle
import (
"context"
"errors"
"sync"
"testing"
"github.com/kandev/kandev/internal/agent/runtime"
"github.com/kandev/kandev/internal/common/logger"
)
// MockRuntime implements Runtime interface for testing
type MockRuntime struct {
name runtime.Name
healthCheckErr error
recoverInstances []*RuntimeInstance
recoverErr error
}
func (m *MockRuntime) Name() runtime.Name { return m.name }
func (m *MockRuntime) HealthCheck(ctx context.Context) error {
return m.healthCheckErr
}
func (m *MockRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) {
return nil, nil
}
func (m *MockRuntime) StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error {
return nil
}
func (m *MockRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) {
return m.recoverInstances, m.recoverErr
}
func newTestRegistryLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
return log
}
func TestNewRuntimeRegistry(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
if registry == nil {
t.Fatal("expected non-nil registry")
}
if registry.runtimes == nil {
t.Error("expected runtimes map to be initialized")
}
if len(registry.runtimes) != 0 {
t.Errorf("expected empty runtimes map, got %d entries", len(registry.runtimes))
}
}
func TestRuntimeRegistry_Register(t *testing.T) {
t.Run("register new runtime", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
mockRT := &MockRuntime{name: runtime.NameDocker}
registry.Register(mockRT)
if len(registry.runtimes) != 1 {
t.Errorf("expected 1 runtime, got %d", len(registry.runtimes))
}
if registry.runtimes[runtime.NameDocker] != mockRT {
t.Error("expected registered runtime to match")
}
})
t.Run("replace existing runtime", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
mockRT1 := &MockRuntime{name: runtime.NameDocker, healthCheckErr: errors.New("error1")}
mockRT2 := &MockRuntime{name: runtime.NameDocker, healthCheckErr: errors.New("error2")}
registry.Register(mockRT1)
registry.Register(mockRT2)
if len(registry.runtimes) != 1 {
t.Errorf("expected 1 runtime after replacement, got %d", len(registry.runtimes))
}
if registry.runtimes[runtime.NameDocker] != mockRT2 {
t.Error("expected runtime to be replaced with new one")
}
})
}
func TestRuntimeRegistry_GetRuntime(t *testing.T) {
t.Run("get existing runtime", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
mockRT := &MockRuntime{name: runtime.NameDocker}
registry.Register(mockRT)
rt, err := registry.GetRuntime(runtime.NameDocker)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if rt != mockRT {
t.Error("expected returned runtime to match registered one")
}
})
t.Run("get non-existent runtime returns ErrRuntimeNotFound", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
_, err := registry.GetRuntime(runtime.NameDocker)
if err == nil {
t.Error("expected error for non-existent runtime")
}
if !errors.Is(err, ErrRuntimeNotFound) {
t.Errorf("expected ErrRuntimeNotFound, got: %v", err)
}
})
}
func TestRuntimeRegistry_List(t *testing.T) {
t.Run("list empty registry", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
names := registry.List()
if len(names) != 0 {
t.Errorf("expected empty list, got %d items", len(names))
}
})
t.Run("list with multiple runtimes", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
registry.Register(&MockRuntime{name: runtime.NameDocker})
registry.Register(&MockRuntime{name: runtime.NameStandalone})
registry.Register(&MockRuntime{name: runtime.NameLocal})
names := registry.List()
if len(names) != 3 {
t.Errorf("expected 3 names, got %d", len(names))
}
// Check all names are present (order is not guaranteed)
nameMap := make(map[runtime.Name]bool)
for _, n := range names {
nameMap[n] = true
}
if !nameMap[runtime.NameDocker] || !nameMap[runtime.NameStandalone] || !nameMap[runtime.NameLocal] {
t.Error("expected all registered runtime names to be present")
}
})
}
func TestRuntimeRegistry_HealthCheckAll(t *testing.T) {
ctx := context.Background()
t.Run("health check with all healthy runtimes", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
registry.Register(&MockRuntime{name: runtime.NameDocker, healthCheckErr: nil})
registry.Register(&MockRuntime{name: runtime.NameStandalone, healthCheckErr: nil})
results := registry.HealthCheckAll(ctx)
if len(results) != 2 {
t.Errorf("expected 2 results, got %d", len(results))
}
if results[runtime.NameDocker] != nil {
t.Errorf("expected nil error for docker, got: %v", results[runtime.NameDocker])
}
if results[runtime.NameStandalone] != nil {
t.Errorf("expected nil error for standalone, got: %v", results[runtime.NameStandalone])
}
})
t.Run("health check with some failing runtimes", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
dockerErr := errors.New("docker unavailable")
registry.Register(&MockRuntime{name: runtime.NameDocker, healthCheckErr: dockerErr})
registry.Register(&MockRuntime{name: runtime.NameStandalone, healthCheckErr: nil})
results := registry.HealthCheckAll(ctx)
if len(results) != 2 {
t.Errorf("expected 2 results, got %d", len(results))
}
if results[runtime.NameDocker] != dockerErr {
t.Errorf("expected docker error, got: %v", results[runtime.NameDocker])
}
if results[runtime.NameStandalone] != nil {
t.Errorf("expected nil error for standalone, got: %v", results[runtime.NameStandalone])
}
})
t.Run("health check with empty registry", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
results := registry.HealthCheckAll(ctx)
if len(results) != 0 {
t.Errorf("expected empty results, got %d", len(results))
}
})
}
func TestRuntimeRegistry_RecoverAll(t *testing.T) {
ctx := context.Background()
t.Run("recovery with no instances", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
registry.Register(&MockRuntime{name: runtime.NameDocker, recoverInstances: nil})
registry.Register(&MockRuntime{name: runtime.NameStandalone, recoverInstances: []*RuntimeInstance{}})
instances, err := registry.RecoverAll(ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(instances) != 0 {
t.Errorf("expected no instances, got %d", len(instances))
}
})
t.Run("recovery with instances from multiple runtimes", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
dockerInstances := []*RuntimeInstance{
{InstanceID: "docker-1", RuntimeName: "docker"},
{InstanceID: "docker-2", RuntimeName: "docker"},
}
standaloneInstances := []*RuntimeInstance{
{InstanceID: "standalone-1", RuntimeName: "standalone"},
}
registry.Register(&MockRuntime{name: runtime.NameDocker, recoverInstances: dockerInstances})
registry.Register(&MockRuntime{name: runtime.NameStandalone, recoverInstances: standaloneInstances})
instances, err := registry.RecoverAll(ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(instances) != 3 {
t.Errorf("expected 3 instances, got %d", len(instances))
}
})
t.Run("recovery with errors continues and returns last error", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
recoverErr := errors.New("recovery failed")
standaloneInstances := []*RuntimeInstance{
{InstanceID: "standalone-1", RuntimeName: "standalone"},
}
registry.Register(&MockRuntime{name: runtime.NameDocker, recoverErr: recoverErr})
registry.Register(&MockRuntime{name: runtime.NameStandalone, recoverInstances: standaloneInstances})
instances, err := registry.RecoverAll(ctx)
// Should still get instances from successful runtimes
// Note: map iteration order is not guaranteed, so we check that at least standalone instances are recovered
if len(instances) == 0 && err == nil {
t.Error("expected either instances or error")
}
// The error may or may not be set depending on iteration order
// If docker is processed last, err will be set; if standalone is processed last, err will be nil
})
t.Run("recovery from empty registry", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
instances, err := registry.RecoverAll(ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(instances) != 0 {
t.Errorf("expected no instances, got %d", len(instances))
}
})
}
func TestRuntimeRegistry_ThreadSafety(t *testing.T) {
log := newTestRegistryLogger()
registry := NewRuntimeRegistry(log)
ctx := context.Background()
const numGoroutines = 50
const numOperations = 100
var wg sync.WaitGroup
wg.Add(numGoroutines * 4) // 4 types of operations
// Concurrent Register operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
name := runtime.Name("test-runtime")
registry.Register(&MockRuntime{name: name})
}
}(i)
}
// Concurrent GetRuntime operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
_, _ = registry.GetRuntime(runtime.Name("test-runtime"))
}
}(i)
}
// Concurrent List operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
_ = registry.List()
}
}(i)
}
// Concurrent HealthCheckAll operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
_ = registry.HealthCheckAll(ctx)
}
}(i)
}
// Wait for all goroutines to complete
wg.Wait()
// If we reach here without a race condition panic, the test passes
// The -race flag during testing will detect actual race conditions
}
@@ -111,13 +111,17 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea
// Create agentctl client pointing to the instance port
client := agentctl.NewClient(r.host, resp.Port, r.logger)
// Extract runtime-specific values from metadata
worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID)
worktreeBranch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch)
// Build metadata
metadata := make(map[string]interface{})
metadata["standalone_port"] = resp.Port
if req.WorktreeID != "" {
metadata["worktree_id"] = req.WorktreeID
if worktreeID != "" {
metadata["worktree_id"] = worktreeID
metadata["worktree_path"] = req.WorkspacePath
metadata["worktree_branch"] = req.WorktreeBranch
metadata["worktree_branch"] = worktreeBranch
}
r.logger.Info("standalone instance created",
@@ -129,6 +133,7 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea
InstanceID: req.InstanceID,
TaskID: req.TaskID,
SessionID: req.SessionID,
RuntimeName: string(r.Name()),
Client: client,
StandaloneInstanceID: resp.ID,
StandalonePort: resp.Port,
@@ -27,6 +27,7 @@ type AgentExecution struct {
WorkspacePath string // Path to the workspace (worktree or repository path)
ACPSessionID string // ACP session ID to resume, if available
AgentCommand string // Command to start the agent subprocess
RuntimeName string // Name of the runtime used (e.g., "docker", "standalone")
Status v1.AgentStatus
StartedAt time.Time
FinishedAt *time.Time
@@ -94,6 +95,9 @@ type LaunchRequest struct {
Metadata map[string]interface{}
ModelOverride string // If set, use this model instead of the profile's model
// Executor configuration - determines which runtime to use
ExecutorType string // Executor type (e.g., "local_pc", "local_docker") - determines runtime
// Worktree configuration
UseWorktree bool // Whether to use a Git worktree for isolation
RepositoryID string // Repository ID for worktree tracking
@@ -8,7 +8,7 @@
"display_name": "Auggie",
"description": "Auggie CLI-powered autonomous coding agent.",
"enabled": true,
"image": "kandev/augment-agent",
"image": "kandev/multi-agent",
"tag": "latest",
"cmd": [
"auggie",
@@ -154,7 +154,7 @@
"display_name": "Codex",
"description": "OpenAI Codex CLI-powered autonomous coding agent using the Codex app-server protocol.",
"enabled": true,
"image": "kandev/codex-agent",
"image": "kandev/multi-agent",
"tag": "latest",
"cmd": [
"codex",
@@ -168,6 +168,11 @@
"source": "{workspace}",
"target": "/workspace",
"read_only": false
},
{
"source": "{home}/.codex",
"target": "/root/.codex",
"read_only": false
}
],
"resource_limits": {
@@ -190,8 +195,8 @@
"resume_via_acp": true,
"resume_flag": "",
"can_recover": true,
"session_dir_template": "{home}/.codex/sessions",
"session_dir_target": "/root/.codex/sessions"
"session_dir_template": "",
"session_dir_target": ""
},
"permission_config": {
"permission_flag": "",
@@ -1,6 +1,10 @@
// Package runtime defines the agent runtime types shared across lifecycle and policy logic.
package runtime
import (
"github.com/kandev/kandev/internal/task/models"
)
// Name identifies the execution runtime.
type Name string
@@ -10,3 +14,19 @@ const (
NameStandalone Name = "standalone"
NameLocal Name = "local"
)
// ExecutorTypeToRuntime maps an ExecutorType to its corresponding runtime Name.
func ExecutorTypeToRuntime(execType models.ExecutorType) Name {
switch execType {
case models.ExecutorTypeLocalPC:
return NameStandalone
case models.ExecutorTypeLocalDocker:
return NameDocker
case models.ExecutorTypeRemoteDocker:
// For now, remote docker uses the same runtime as local docker.
// Future: separate runtime for remote docker.
return NameDocker
default:
return NameStandalone
}
}
+10 -8
View File
@@ -53,6 +53,10 @@ type NATSConfig struct {
// DockerConfig holds Docker client configuration.
type DockerConfig struct {
// Enabled controls whether the Docker runtime is available for task execution.
// When true and Docker is accessible, tasks can use Docker-based executors.
// Default: true (Docker runtime is enabled if Docker is available)
Enabled bool `mapstructure:"enabled"`
Host string `mapstructure:"host"`
APIVersion string `mapstructure:"apiVersion"`
TLSVerify bool `mapstructure:"tlsVerify"`
@@ -89,19 +93,17 @@ type WorktreeConfig struct {
}
// AgentConfig holds agent runtime configuration.
// Note: Runtime selection is now per-task based on executor type, not global.
// The Standalone runtime (agentctl) always runs as a core service.
// Docker runtime is available when docker.enabled=true.
type AgentConfig struct {
// Runtime specifies the agent runtime mode: "docker" or "standalone"
// - "standalone": Agents run via standalone agentctl on the host machine (default)
// - "docker": Agents run in Docker containers
Runtime string `mapstructure:"runtime"`
// StandaloneHost is the host where standalone agentctl is running (default: localhost)
StandaloneHost string `mapstructure:"standaloneHost"`
// StandalonePort is the control port for standalone agentctl (default: 9999)
StandalonePort int `mapstructure:"standalonePort"`
// McpServerEnabled enables the embedded MCP server (default: true in standalone mode)
// McpServerEnabled enables the embedded MCP server (default: true)
McpServerEnabled bool `mapstructure:"mcpServerEnabled"`
// McpServerPort is the port for the embedded MCP server (default: 9090)
@@ -153,14 +155,14 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("nats.maxReconnects", 10)
// Docker defaults
v.SetDefault("docker.enabled", true) // Docker runtime enabled by default if Docker is available
v.SetDefault("docker.host", "unix:///var/run/docker.sock")
v.SetDefault("docker.apiVersion", "1.41")
v.SetDefault("docker.tlsVerify", false)
v.SetDefault("docker.defaultNetwork", "kandev-network")
v.SetDefault("docker.volumeBasePath", "/var/lib/kandev/volumes")
// Agent defaults
v.SetDefault("agent.runtime", "standalone")
// Agent defaults (runtime selection is now per-task based on executor type)
v.SetDefault("agent.standaloneHost", "localhost")
v.SetDefault("agent.standalonePort", 9999)
v.SetDefault("agent.mcpServerEnabled", true)
@@ -61,7 +61,7 @@ func (c *Controller) TriggerTask(ctx context.Context, req dto.TriggerTaskRequest
// StartTask starts task execution
func (c *Controller) StartTask(ctx context.Context, req dto.StartTaskRequest) (dto.StartTaskResponse, error) {
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.Priority, req.Prompt)
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.Priority, req.Prompt)
if err != nil {
return dto.StartTaskResponse{}, err
}
@@ -43,6 +43,7 @@ type TriggerTaskResponse struct {
type StartTaskRequest struct {
TaskID string `json:"task_id"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local_pc, local_docker, etc.)
Priority int `json:"priority,omitempty"`
Prompt string `json:"prompt,omitempty"` // Initial prompt to send to the agent
}
@@ -86,6 +86,7 @@ type LaunchAgentRequest struct {
Env map[string]string
ACPSessionID string // ACP session ID to resume, if available
ModelOverride string // If set, use this model instead of the profile's model
ExecutorType string // Executor type (e.g., "local_pc", "local_docker") - determines runtime
// Worktree configuration for concurrent agent execution
UseWorktree bool // Whether to use a Git worktree for isolation
@@ -201,12 +202,14 @@ func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]st
// Execute starts agent execution for a task
func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) {
return e.ExecuteWithProfile(ctx, task, "", task.Description)
return e.ExecuteWithProfile(ctx, task, "", "", task.Description)
}
// ExecuteWithProfile starts agent execution for a task using an explicit agent profile.
// The executorID parameter specifies which executor to use (determines runtime: local_pc, local_docker, etc.).
// If executorID is empty, falls back to workspace's default executor.
// The prompt parameter is the initial prompt to send to the agent.
func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, prompt string) (*TaskExecution, error) {
func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, prompt string) (*TaskExecution, error) {
if agentProfileID == "" {
e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID))
return nil, ErrNoAgentProfileID
@@ -315,9 +318,16 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
UpdatedAt: now,
AgentProfileSnapshot: agentProfileSnapshot,
}
if executorID := e.defaultExecutorID(ctx, task.WorkspaceID); executorID != "" {
session.ExecutorID = executorID
metadata = e.applyExecutorMetadata(ctx, metadata, executorID)
// Resolve executor configuration
execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata)
if execConfig.ExecutorID != "" {
session.ExecutorID = execConfig.ExecutorID
metadata = execConfig.Metadata
req.ExecutorType = execConfig.ExecutorType
e.logger.Debug("resolved executor for task",
zap.String("task_id", task.ID),
zap.String("executor_id", execConfig.ExecutorID),
zap.String("executor_type", execConfig.ExecutorType))
}
if err := e.repo.CreateTaskSession(ctx, session); err != nil {
@@ -335,6 +345,7 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
e.logger.Info("launching agent for task",
zap.String("task_id", task.ID),
zap.String("agent_profile_id", agentProfileID),
zap.String("executor_type", req.ExecutorType),
zap.Bool("use_worktree", req.UseWorktree))
req.Env = e.applyPreferredShellEnv(ctx, req.Env)
@@ -518,12 +529,24 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio
if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" {
metadata["worktree_id"] = session.Worktrees[0].WorktreeID
}
if session.ExecutorID == "" {
if executorID := e.defaultExecutorID(ctx, task.WorkspaceID); executorID != "" {
session.ExecutorID = executorID
// Resolve executor configuration
executorWasEmpty := session.ExecutorID == ""
execConfig := e.resolveExecutorConfig(ctx, session.ExecutorID, task.WorkspaceID, metadata)
session.ExecutorID = execConfig.ExecutorID
metadata = execConfig.Metadata
req.ExecutorType = execConfig.ExecutorType
// Persist executor assignment if it was resolved from workspace default
if executorWasEmpty && session.ExecutorID != "" {
session.UpdatedAt = time.Now().UTC()
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
e.logger.Warn("failed to persist executor assignment for session",
zap.String("session_id", session.ID),
zap.String("executor_id", session.ExecutorID),
zap.Error(err))
// Continue anyway - this is not fatal
}
}
metadata = e.applyExecutorMetadata(ctx, metadata, session.ExecutorID)
if len(metadata) > 0 {
req.Metadata = metadata
}
@@ -580,6 +603,7 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio
zap.String("task_id", session.TaskID),
zap.String("session_id", session.ID),
zap.String("agent_profile_id", session.AgentProfileID),
zap.String("executor_type", req.ExecutorType),
zap.String("resume_token", req.ACPSessionID),
zap.Bool("use_worktree", req.UseWorktree))
@@ -869,6 +893,9 @@ func (e *Executor) SwitchModel(ctx context.Context, taskID, sessionID, newModel,
zap.String("agent_execution_id", oldAgentExecutionID))
}
// Resolve executor configuration
execConfig := e.resolveExecutorConfig(ctx, session.ExecutorID, task.WorkspaceID, nil)
// Build a new launch request with the model override
req := &LaunchAgentRequest{
TaskID: task.ID,
@@ -878,18 +905,41 @@ func (e *Executor) SwitchModel(ctx context.Context, taskID, sessionID, newModel,
TaskDescription: prompt,
ModelOverride: newModel, // This is the key - use the new model
ACPSessionID: acpSessionID,
ExecutorType: execConfig.ExecutorType,
Metadata: execConfig.Metadata,
}
// Get repository info if available
var repositoryPath string
if session.RepositoryID != "" {
repository, err := e.repo.GetRepository(ctx, session.RepositoryID)
if err == nil && repository != nil {
repositoryPath = repository.LocalPath
req.RepositoryURL = repository.LocalPath
req.Branch = session.BaseBranch
}
}
// Get worktree info from running state
// Configure worktree if enabled - reuse existing worktree from session
if e.worktreeEnabled && repositoryPath != "" {
req.UseWorktree = true
req.RepositoryPath = repositoryPath
req.RepositoryID = session.RepositoryID
if session.BaseBranch != "" {
req.BaseBranch = session.BaseBranch
} else {
req.BaseBranch = "main"
}
// Pass existing worktree ID in metadata for reuse
if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" {
if req.Metadata == nil {
req.Metadata = make(map[string]interface{})
}
req.Metadata["worktree_id"] = session.Worktrees[0].WorktreeID
}
}
// Get worktree info from running state (for workspace path)
if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil {
if running.WorktreePath != "" {
req.RepositoryURL = running.WorktreePath
@@ -902,7 +952,10 @@ func (e *Executor) SwitchModel(ctx context.Context, taskID, sessionID, newModel,
zap.String("task_id", task.ID),
zap.String("session_id", sessionID),
zap.String("model", newModel),
zap.String("acp_session_id", acpSessionID))
zap.String("executor_type", req.ExecutorType),
zap.String("acp_session_id", acpSessionID),
zap.Bool("use_worktree", req.UseWorktree),
zap.String("repository_path", req.RepositoryPath))
// Launch the new agent
resp, err := e.agentManager.LaunchAgent(ctx, req)
@@ -1081,6 +1134,51 @@ func (e *Executor) applyExecutorMetadata(ctx context.Context, metadata map[strin
return metadata
}
// getExecutorType resolves an executor ID to its type string.
// Returns empty string if executor not found or on error.
func (e *Executor) getExecutorType(ctx context.Context, executorID string) string {
if executorID == "" {
return ""
}
executor, err := e.repo.GetExecutor(ctx, executorID)
if err != nil || executor == nil {
return ""
}
return string(executor.Type)
}
// executorConfig holds resolved executor configuration.
type executorConfig struct {
ExecutorID string
ExecutorType string
Metadata map[string]interface{}
}
// resolveExecutorConfig resolves executor configuration from an executor ID.
// If executorID is empty, it falls back to the workspace default.
// Returns the resolved config with executor ID, type, and metadata.
func (e *Executor) resolveExecutorConfig(ctx context.Context, executorID, workspaceID string, existingMetadata map[string]interface{}) executorConfig {
resolved := executorID
if resolved == "" {
resolved = e.defaultExecutorID(ctx, workspaceID)
}
metadata := existingMetadata
if metadata == nil {
metadata = make(map[string]interface{})
}
if resolved != "" {
metadata = e.applyExecutorMetadata(ctx, metadata, resolved)
}
return executorConfig{
ExecutorID: resolved,
ExecutorType: e.getExecutorType(ctx, resolved),
Metadata: metadata,
}
}
func cloneMetadata(src map[string]interface{}) map[string]interface{} {
if len(src) == 0 {
return nil
@@ -84,6 +84,7 @@ func (h *Handlers) wsTriggerTask(ctx context.Context, msg *ws.Message) (*ws.Mess
type wsStartTaskRequest struct {
TaskID string `json:"task_id"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
Priority int `json:"priority,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
@@ -103,6 +104,7 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag
resp, err := h.controller.StartTask(ctx, dto.StartTaskRequest{
TaskID: req.TaskID,
AgentProfileID: req.AgentProfileID,
ExecutorID: req.ExecutorID,
Priority: req.Priority,
Prompt: req.Prompt,
})
@@ -42,10 +42,11 @@ func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error {
}
// StartTask manually starts agent execution for a task
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, priority int, prompt string) (*executor.TaskExecution, error) {
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string) (*executor.TaskExecution, error) {
s.logger.Debug("manually starting task",
zap.String("task_id", taskID),
zap.String("agent_profile_id", agentProfileID),
zap.String("executor_id", executorID),
zap.Int("priority", priority),
zap.Int("prompt_length", len(prompt)))
@@ -75,7 +76,7 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s
effectivePrompt = task.Description
}
execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, effectivePrompt)
execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, executorID, effectivePrompt)
if err != nil {
return nil, err
}
@@ -374,7 +374,7 @@ func getExecutionStore(t *testing.T, mgr *lifecycle.Manager) *lifecycle.Executio
func newLifecycleManager(t *testing.T, log *logger.Logger) *lifecycle.Manager {
t.Helper()
reg := registry.NewRegistry(log)
return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, nil, log)
return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, nil, lifecycle.RuntimeFallbackWarn, log)
}
func newAgentctlClient(t *testing.T, serverURL string, log *logger.Logger) *agentctlclient.Client {
@@ -23,7 +23,7 @@ type TaskHandlers struct {
}
type OrchestratorStarter interface {
StartTask(ctx context.Context, taskID string, agentProfileID string, priority int, prompt string) (*executor.TaskExecution, error)
StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string) (*executor.TaskExecution, error)
}
func NewTaskHandlers(ctrl *controller.TaskController, orchestrator OrchestratorStarter, log *logger.Logger) *TaskHandlers {
@@ -129,6 +129,7 @@ type httpCreateTaskRequest struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
StartAgent bool `json:"start_agent,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
}
type createTaskResponse struct {
@@ -191,12 +192,13 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
// Launch agent asynchronously so the HTTP request can return immediately.
// The frontend will receive WebSocket updates when the agent actually starts.
// This allows the "new task" dialog to close right away while setup scripts run.
executorID := body.ExecutorID // Capture for goroutine
go func() {
// Use a longer timeout to accommodate setup scripts (which can take minutes for npm install, etc.)
startCtx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout)
defer cancel()
// Use task description as the initial prompt
execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, body.Priority, resp.Description)
execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, executorID, body.Priority, resp.Description)
if err != nil {
h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", resp.ID))
return
@@ -204,6 +206,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
h.logger.Info("agent started for task (async)",
zap.String("task_id", resp.ID),
zap.String("session_id", execution.SessionID),
zap.String("executor_id", executorID),
zap.String("execution_id", execution.AgentExecutionID))
}()
}
@@ -356,6 +359,7 @@ type wsCreateTaskRequest struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
StartAgent bool `json:"start_agent,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
}
func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -414,11 +418,15 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
response := createTaskResponse{TaskDTO: resp}
if req.StartAgent && req.AgentProfileID != "" && h.orchestrator != nil {
// Use task description as the initial prompt
execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.Priority, resp.Description)
execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description)
if err != nil {
h.logger.Error("failed to start agent for task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start agent for task", nil)
}
h.logger.Info("wsCreateTask started agent",
zap.String("task_id", resp.ID),
zap.String("executor_id", req.ExecutorID),
zap.String("session_id", execution.SessionID))
response.TaskSessionID = execution.SessionID
response.AgentExecutionID = execution.AgentExecutionID
}
+15 -12
View File
@@ -519,6 +519,17 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error {
}
}
// Stop any running execution BEFORE deleting from database
// This must happen first because StopTask queries the database for active sessions
if s.executionStopper != nil {
if err := s.executionStopper.StopTask(ctx, id, "task deleted", true); err != nil {
s.logger.Warn("failed to stop task execution on delete",
zap.String("task_id", id),
zap.Error(err))
// Continue with deletion even if stop fails
}
}
if err := s.repo.DeleteTask(ctx, id); err != nil {
s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err))
return err
@@ -529,9 +540,9 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error {
zap.String("task_id", id),
zap.Duration("duration", time.Since(start)))
// Perform cleanup synchronously with dedicated timeout
// Perform remaining cleanup synchronously with dedicated timeout
// Use background context since the original request may complete
if s.executionStopper != nil || s.worktreeCleanup != nil || len(sessions) > 0 {
if s.worktreeCleanup != nil || len(sessions) > 0 {
cleanupStart := time.Now()
cleanupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -554,6 +565,8 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error {
}
// performTaskCleanup handles post-deletion cleanup operations.
// Note: Execution stopping is done BEFORE database deletion in DeleteTask,
// so this function only handles worktree cleanup and executor_running records.
// Returns a slice of errors encountered (empty if all succeeded).
func (s *Service) performTaskCleanup(
ctx context.Context,
@@ -563,16 +576,6 @@ func (s *Service) performTaskCleanup(
) []error {
var errs []error
// Stop any running execution
if s.executionStopper != nil {
if err := s.executionStopper.StopTask(ctx, taskID, "task deleted", true); err != nil {
s.logger.Warn("failed to stop task execution on delete",
zap.String("task_id", taskID),
zap.Error(err))
errs = append(errs, fmt.Errorf("stop execution: %w", err))
}
}
// Cleanup worktrees
if len(worktrees) > 0 {
if cleaner, ok := s.worktreeCleanup.(WorktreeBatchCleaner); ok {
@@ -576,11 +576,13 @@ export function TaskCreateDialog({
state: targetState,
start_agent: Boolean(startAgent && !isEditMode && agentProfileId),
agent_profile_id: startAgent ? agentProfileId || undefined : undefined,
executor_id: startAgent ? executorId || undefined : undefined,
});
console.log('[TaskCreateDialog] task created', {
taskId: taskResponse.id,
startAgent,
agentProfileId,
executorId,
});
onSuccess?.(taskResponse, 'create', {
taskSessionId: taskResponse.session_id ?? null,
@@ -164,6 +164,7 @@ export function SessionsDropdown({
{
task_id: taskId,
agent_profile_id: data.agentProfileId,
executor_id: data.executorId,
prompt: data.prompt.trim(),
},
15000
+1
View File
@@ -38,6 +38,7 @@ export async function createTask(
state?: Task['state'];
start_agent?: boolean;
agent_profile_id?: string;
executor_id?: string;
},
options?: ApiRequestOptions
) {
@@ -0,0 +1,560 @@
# Task-Specific Executor Selection Architecture Refactoring
> **Status**: Proposal
> **Author**: Generated for architectural review
> **Date**: 2026-01-25
## Executive Summary
This document proposes refactoring the executor selection architecture from a global runtime configuration to a per-task executor selection model. The goal is to enable multiple runtimes (Docker and Standalone) to coexist, with each task independently selecting its execution environment.
---
## 1. Current Architecture
### 1.1 Global Runtime Configuration
The backend currently uses a **global runtime configuration** that determines how ALL agents are launched:
```go
// apps/backend/internal/common/config/config.go
type AgentConfig struct {
Runtime string // "docker" or "standalone" - GLOBAL for all tasks
StandaloneHost string
StandalonePort int
}
```
The `provideLifecycleManager()` function in `apps/backend/cmd/kandev/agents.go` creates a **single runtime** based on this global config:
```go
func provideLifecycleManager(...) (*lifecycle.Manager, *registry.Registry, error) {
var agentRuntime lifecycle.Runtime
switch cfg.Agent.Runtime {
case "standalone":
agentRuntime = lifecycle.NewStandaloneRuntime(...) // ALL tasks use this
default:
agentRuntime = lifecycle.NewDockerRuntime(...) // OR ALL tasks use this
}
// Only ONE runtime is created and passed to the Manager
lifecycleMgr := lifecycle.NewManager(..., agentRuntime, ...)
}
```
### 1.2 Why This Is Limiting
1. **All-or-nothing approach**: Every task uses either Docker OR Standalone, never both
2. **No runtime flexibility**: Can't run lightweight tasks on host while running isolated tasks in Docker
3. **Configuration at startup**: Runtime is fixed when the backend starts; changing requires restart
4. **Underutilized Executor model**: The database has `Executor` with `Type` field (`local_pc`, `local_docker`) but it's not used for runtime selection
### 1.3 Current Data Flow
```
Task Creation
│
├── TaskSession.ExecutorID is set (from workspace default)
│ But it's only used for metadata (MCP policy), not runtime selection
│
└── executor.ExecuteWithProfile()
│
└── LaunchAgent() → lifecycle.Manager.Launch()
│
└── m.runtime.CreateInstance() ← Uses GLOBAL runtime
```
### 1.4 Existing Executor Infrastructure
The database already has:
- `executors` table with `type` field (local_pc, local_docker, remote_docker, etc.)
- `task_sessions.executor_id` foreign key
- `executors_running.runtime` field (currently underutilized)
- Default executors seeded: "Local PC" (exec-local-pc) and "Local Docker" (exec-local-docker)
---
## 2. Proposed Architecture
### 2.1 Core Change: Multi-Runtime Manager
Replace the single-runtime lifecycle manager with a **runtime registry** that can dispatch to multiple runtimes based on executor type:
```go
// NEW: RuntimeRegistry manages multiple runtimes
type RuntimeRegistry struct {
runtimes map[runtime.Name]Runtime // docker, standalone, etc.
logger *logger.Logger
}
func (r *RuntimeRegistry) GetRuntime(name runtime.Name) (Runtime, error) {
rt, ok := r.runtimes[name]
if !ok {
return nil, fmt.Errorf("runtime %q not available", name)
}
return rt, nil
}
// Lifecycle Manager now uses RuntimeRegistry instead of single Runtime
type Manager struct {
runtimeRegistry *RuntimeRegistry // Replaces: runtime Runtime
// ... rest unchanged
}
```
### 2.2 Executor Type → Runtime Mapping
```go
// ExecutorType to Runtime mapping
func ExecutorTypeToRuntime(execType models.ExecutorType) runtime.Name {
switch execType {
case models.ExecutorTypeLocalPC:
return runtime.NameStandalone
case models.ExecutorTypeLocalDocker:
return runtime.NameDocker
case models.ExecutorTypeRemoteDocker:
return runtime.NameDocker // Future: separate RemoteDockerRuntime
default:
return runtime.NameStandalone // Fallback
}
}
```
### 2.3 New Data Flow
```
Task Creation (with executor_id)
│
├── TaskSession.ExecutorID → Lookup Executor → Get ExecutorType
│
└── executor.ExecuteWithProfile()
│
└── LaunchAgentRequest now includes ExecutorType
│
└── lifecycle.Manager.Launch()
│
├── Resolve runtime: ExecutorTypeToRuntime(req.ExecutorType)
│
└── runtimeRegistry.GetRuntime(runtimeName).CreateInstance()
```
### 2.4 Configuration Changes
**Remove** the global `cfg.Agent.Runtime` setting. Instead:
```yaml
# BEFORE: Global runtime selection (to be removed)
agent:
runtime: "standalone" # DELETE THIS
# AFTER: Runtime availability configuration
agent:
docker:
enabled: true
# Docker-specific config (image, network, etc.)
standalone:
host: "localhost"
port: 9999
# NOTE: No "enabled" flag - agentctl always runs as a core service
```
**Important**: The `standalone` section configures connection settings for the agentctl process, but there's no `enabled` flag. The agentctl launcher always starts as a core service - it's the control plane for standalone execution and is always available.
---
## 3. Files Requiring Modification
### 3.1 Configuration Layer
| File | Change |
|------|--------|
| `internal/common/config/config.go` | Remove `Runtime` field; add `Docker.Enabled` (standalone is always on) |
| `cmd/kandev/agents.go` | Refactor `provideLifecycleManager()` to create RuntimeRegistry with both runtimes |
| `cmd/kandev/agentctl.go` | **Always start agentctl launcher** (remove conditional check) |
### 3.2 Lifecycle Manager
| File | Change |
|------|--------|
| `internal/agent/lifecycle/manager.go` | Replace `runtime Runtime` with `runtimeRegistry *RuntimeRegistry`; update `Launch()` to select runtime via registry |
| `internal/agent/lifecycle/runtime_registry.go` | **NEW FILE**: RuntimeRegistry implementation |
| `internal/agent/lifecycle/runtime.go` | Add `ExecutorTypeToRuntime()` mapping function |
> **Design Principle**: Keep `manager.go` free of runtime-specific code (no Docker imports, no standalone-specific logic). All runtime-specific behavior should be encapsulated in the `Runtime` interface implementations (`runtime_docker.go`, `runtime_standalone.go`) and dispatched through the `RuntimeRegistry`. The manager should only interact with the abstract `Runtime` interface.
### 3.3 Orchestrator/Executor
| File | Change |
|------|--------|
| `internal/orchestrator/executor/executor.go` | Pass `ExecutorType` in `LaunchAgentRequest`; resolve executor before launch |
| `internal/agent/lifecycle/types.go` | Add `ExecutorType` field to `LaunchRequest` |
| `cmd/kandev/adapters.go` | Pass `ExecutorType` from executor request to lifecycle request |
### 3.4 Data Layer
| File | Change |
|------|--------|
| `internal/task/models/models.go` | Add helper: `Executor.GetRuntimeName()` |
| `internal/agent/runtime/runtime.go` | Add `ExecutorTypeToRuntime()` function |
### 3.5 Recovery/Resumption
| File | Change |
|------|--------|
| `internal/orchestrator/service.go` | `resumeExecutorsOnStartup()` must iterate all available runtimes |
| `internal/agent/lifecycle/manager.go` | `RecoverExecutions()` must query each runtime in registry |
---
## 4. Implementation Plan
### Phase 1: Infrastructure Setup (Non-Breaking)
1. **Create RuntimeRegistry** (`runtime_registry.go`)
- Implement registry with `Register()`, `GetRuntime()`, `List()`, `HealthCheckAll()`
- Add `ExecutorTypeToRuntime()` mapping function
2. **Update Configuration**
- Add `Docker.Enabled` and `Standalone.Enabled` fields
- Keep `Runtime` field temporarily for backward compatibility
- Add deprecation warning if `Runtime` is used
3. **Refactor `provideLifecycleManager()`**
- Create both runtimes if enabled
- Build RuntimeRegistry with available runtimes
- Pass registry to Manager (but Manager still uses single runtime internally)
### Phase 2: Lifecycle Manager Refactoring
4. **Update Manager to use RuntimeRegistry**
- Change `runtime Runtime` to `runtimeRegistry *RuntimeRegistry`
- Add `ExecutorType` to `LaunchRequest`
- Update `Launch()` to:
```go
runtimeName := ExecutorTypeToRuntime(req.ExecutorType)
rt, err := m.runtimeRegistry.GetRuntime(runtimeName)
if err != nil {
return nil, fmt.Errorf("runtime not available: %w", err)
}
runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq)
```
5. **Update RecoverExecutions()**
- Iterate all registered runtimes
- Merge recovered instances from each runtime
### Phase 3: Executor Integration
6. **Update `LaunchAgentRequest`**
- Add `ExecutorType models.ExecutorType` field
7. **Update `executor.ExecuteWithProfile()`**
- Resolve executor before launch
- Pass executor type in request
8. **Update `lifecycleAdapter.LaunchAgent()`**
- Map executor type to lifecycle request
### Phase 4: Cleanup
9. **Remove global `cfg.Agent.Runtime`**
- Delete from config struct
- Update flag parsing
- Update documentation
10. **Update agentctl launcher logic**
- **Always start** the agentctl launcher (remove all conditional checks)
- agentctl is a core service, not dependent on runtime configuration
---
## 5. Edge Cases and Challenges
### 5.1 Session Resumption Across Runtimes
**Challenge**: A session started on Docker cannot be resumed on Standalone (different environment).
**Solution**: Store runtime name in `executors_running.runtime` and validate on resume:
```go
func (e *Executor) ResumeSession(...) {
running, _ := e.repo.GetExecutorRunningBySessionID(session.ID)
if running != nil && running.Runtime != "" {
// Use the same runtime that was used originally
req.ExecutorType = RuntimeToExecutorType(running.Runtime)
}
}
```
### 5.2 Runtime Availability Changes
**Challenge**: Task was created with Docker executor, but Docker is disabled on restart.
**Solution**:
- Check runtime availability before launch
- Return clear error: "Runtime 'docker' is not available. Enable it in configuration or change the task's executor."
- Frontend can show unavailable executors as disabled
### 5.3 Fallback Behavior
**Challenge**: Executor not set on task/session.
**Solution**: Define precedence:
1. Session's executor (if resuming)
2. Workspace default executor
3. System default ("Local PC" → Standalone)
### 5.4 agentctl Architecture
**Clarification**: agentctl runs in **both** modes:
- **Docker**: agentctl runs inside each container (started by entrypoint)
- **Standalone**: agentctl runs as a subprocess on the host, managing multiple agent instances
**Current**: agentctl launcher only started if `cfg.Agent.Runtime == "standalone"`
**New**: agentctl launcher should **always start** on the host. It serves as the control plane for standalone execution. The launcher startup should not depend on any runtime configuration - it's a core service that's always available.
### 5.5 Container Recovery
**Challenge**: Docker runtime recovers containers on startup; Standalone doesn't persist instances.
**Solution**:
- Docker: `RecoverInstances()` finds containers by label
- Standalone: Returns empty (sessions recreated on resume via `LaunchAgent()`)
- RuntimeRegistry: Iterates all runtimes, merges results
---
## 6. Database Schema Changes
No schema changes required. Existing fields are sufficient:
- `task_sessions.executor_id` - Links to selected executor
- `executors.type` - Determines runtime (local_pc → standalone, local_docker → docker)
- `executors_running.runtime` - Records which runtime was used
---
## 7. API Changes
### 7.1 Task Creation (No Change Needed)
The `executor_id` can already be passed via:
- Workspace default (`workspace.default_executor_id`)
- Potentially extended to task creation payload in future
### 7.2 New: Runtime Status Endpoint
```json
GET /api/v1/runtimes/status
{
"runtimes": [
{ "name": "standalone", "available": true, "healthy": true },
{ "name": "docker", "available": true, "healthy": true }
]
}
```
This allows the frontend to:
- Show which executors are available
- Disable executor selection for unavailable runtimes
---
## 8. Testing Strategy
1. **Unit Tests**
- `RuntimeRegistry.GetRuntime()` for registered/unregistered runtimes
- `ExecutorTypeToRuntime()` mapping
- `Launch()` with different executor types
2. **Integration Tests**
- Start task with `local_pc` executor → uses Standalone runtime
- Start task with `local_docker` executor → uses Docker runtime
- Resume session maintains original runtime
- Backend restart with Docker disabled → sessions on Docker can't resume
3. **E2E Tests**
- Create workspace with Docker as default executor
- Start task → runs in Docker container
- Change workspace default to Local PC
- Start new task → runs via Standalone
- Both tasks run concurrently with different runtimes
---
## 9. Code Examples
### 9.1 RuntimeRegistry Implementation
```go
// internal/agent/lifecycle/runtime_registry.go
package lifecycle
import (
"context"
"fmt"
"sync"
"github.com/kandev/kandev/internal/agent/runtime"
"github.com/kandev/kandev/internal/common/logger"
)
type RuntimeRegistry struct {
runtimes map[runtime.Name]Runtime
mu sync.RWMutex
logger *logger.Logger
}
func NewRuntimeRegistry(log *logger.Logger) *RuntimeRegistry {
return &RuntimeRegistry{
runtimes: make(map[runtime.Name]Runtime),
logger: log,
}
}
func (r *RuntimeRegistry) Register(rt Runtime) {
r.mu.Lock()
defer r.mu.Unlock()
r.runtimes[rt.Name()] = rt
r.logger.Info("registered runtime", zap.String("name", string(rt.Name())))
}
func (r *RuntimeRegistry) GetRuntime(name runtime.Name) (Runtime, error) {
r.mu.RLock()
defer r.mu.RUnlock()
rt, ok := r.runtimes[name]
if !ok {
return nil, fmt.Errorf("runtime %q not registered", name)
}
return rt, nil
}
func (r *RuntimeRegistry) List() []runtime.Name {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]runtime.Name, 0, len(r.runtimes))
for name := range r.runtimes {
names = append(names, name)
}
return names
}
func (r *RuntimeRegistry) HealthCheckAll(ctx context.Context) map[runtime.Name]error {
r.mu.RLock()
defer r.mu.RUnlock()
results := make(map[runtime.Name]error)
for name, rt := range r.runtimes {
results[name] = rt.HealthCheck(ctx)
}
return results
}
func (r *RuntimeRegistry) RecoverAll(ctx context.Context) ([]*RuntimeInstance, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var allRecovered []*RuntimeInstance
for _, rt := range r.runtimes {
recovered, err := rt.RecoverInstances(ctx)
if err != nil {
r.logger.Warn("failed to recover instances from runtime",
zap.String("runtime", string(rt.Name())),
zap.Error(err))
continue
}
allRecovered = append(allRecovered, recovered...)
}
return allRecovered, nil
}
```
### 9.2 Updated provideLifecycleManager
```go
// apps/backend/cmd/kandev/agents.go
func provideLifecycleManager(
ctx context.Context,
cfg *config.Config,
log *logger.Logger,
eventBus bus.EventBus,
dockerClient *docker.Client,
agentSettingsRepo settingsstore.Repository,
) (*lifecycle.Manager, *registry.Registry, error) {
// Create runtime registry
runtimeRegistry := lifecycle.NewRuntimeRegistry(log)
// Standalone runtime is always available (agentctl is a core service)
controlClient := agentctl.NewControlClient(
cfg.Agent.Standalone.Host,
cfg.Agent.Standalone.Port,
log,
)
standaloneRuntime := lifecycle.NewStandaloneRuntime(
controlClient,
cfg.Agent.Standalone.Host,
cfg.Agent.Standalone.Port,
log,
)
runtimeRegistry.Register(standaloneRuntime)
log.Info("Standalone runtime registered")
// Register Docker runtime if enabled and Docker client is available
if cfg.Agent.Docker.Enabled && dockerClient != nil {
dockerRuntime := lifecycle.NewDockerRuntime(dockerClient, log)
runtimeRegistry.Register(dockerRuntime)
log.Info("Docker runtime registered")
}
// ... rest of initialization using runtimeRegistry
// NOTE: Manager receives the registry, not individual runtimes
// This keeps manager.go free of runtime-specific code
lifecycleMgr := lifecycle.NewManager(
agentRegistry, eventBus, runtimeRegistry, /* ... */
)
return lifecycleMgr, agentRegistry, nil
}
```
### 9.3 Updated Launch Method
```go
// internal/agent/lifecycle/manager.go
func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) {
// ... existing validation and setup ...
// Determine which runtime to use based on executor type
runtimeName := runtime.ExecutorTypeToRuntime(req.ExecutorType)
if runtimeName == runtime.NameUnknown {
runtimeName = runtime.NameStandalone // Default fallback
}
rt, err := m.runtimeRegistry.GetRuntime(runtimeName)
if err != nil {
return nil, fmt.Errorf("runtime %q not available for executor type %q: %w",
runtimeName, req.ExecutorType, err)
}
// Create runtime instance using the selected runtime
runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq)
if err != nil {
return nil, fmt.Errorf("failed to create instance: %w", err)
}
// Store runtime name for session resumption
execution := runtimeInstance.ToAgentExecution(runtimeReq)
execution.RuntimeName = runtimeName // NEW FIELD
// ... rest unchanged ...
}
```
---
## 10. Migration Path
Since breaking changes are acceptable, the migration is straightforward:
1. **Deploy new backend** with both runtimes enabled
2. **Running sessions continue** (Docker containers persist; Standalone sessions will need resume)
3. **New tasks** use executor-specific runtime selection
4. **Remove old config** after verifying stability
No database migration needed - schema already supports this model.