18 KiB
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:
// 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:
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
- All-or-nothing approach: Every task uses either Docker OR Standalone, never both
- No runtime flexibility: Can't run lightweight tasks on host while running isolated tasks in Docker
- Configuration at startup: Runtime is fixed when the backend starts; changing requires restart
- Underutilized Executor model: The database has
ExecutorwithTypefield (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:
executorstable withtypefield (local_pc, local_docker, remote_docker, etc.)task_sessions.executor_idforeign keyexecutors_running.runtimefield (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:
// 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
// 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:
# 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.gofree of runtime-specific code (no Docker imports, no standalone-specific logic). All runtime-specific behavior should be encapsulated in theRuntimeinterface implementations (runtime_docker.go,runtime_standalone.go) and dispatched through theRuntimeRegistry. The manager should only interact with the abstractRuntimeinterface.
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)
-
Create RuntimeRegistry (
runtime_registry.go)- Implement registry with
Register(),GetRuntime(),List(),HealthCheckAll() - Add
ExecutorTypeToRuntime()mapping function
- Implement registry with
-
Update Configuration
- Add
Docker.EnabledandStandalone.Enabledfields - Keep
Runtimefield temporarily for backward compatibility - Add deprecation warning if
Runtimeis used
- Add
-
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
-
Update Manager to use RuntimeRegistry
- Change
runtime RuntimetoruntimeRegistry *RuntimeRegistry - Add
ExecutorTypetoLaunchRequest - Update
Launch()to: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)
- Change
-
Update RecoverExecutions()
- Iterate all registered runtimes
- Merge recovered instances from each runtime
Phase 3: Executor Integration
-
Update
LaunchAgentRequest- Add
ExecutorType models.ExecutorTypefield
- Add
-
Update
executor.ExecuteWithProfile()- Resolve executor before launch
- Pass executor type in request
-
Update
lifecycleAdapter.LaunchAgent()- Map executor type to lifecycle request
Phase 4: Cleanup
-
Remove global
cfg.Agent.Runtime- Delete from config struct
- Update flag parsing
- Update documentation
-
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:
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:
- Session's executor (if resuming)
- Workspace default executor
- 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 executorexecutors.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
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
-
Unit Tests
RuntimeRegistry.GetRuntime()for registered/unregistered runtimesExecutorTypeToRuntime()mappingLaunch()with different executor types
-
Integration Tests
- Start task with
local_pcexecutor → uses Standalone runtime - Start task with
local_dockerexecutor → uses Docker runtime - Resume session maintains original runtime
- Backend restart with Docker disabled → sessions on Docker can't resume
- Start task with
-
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
// 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
// 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
// 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:
- Deploy new backend with both runtimes enabled
- Running sessions continue (Docker containers persist; Standalone sessions will need resume)
- New tasks use executor-specific runtime selection
- Remove old config after verifying stability
No database migration needed - schema already supports this model.