// Package lifecycle provides agent runtime abstractions. package lifecycle import ( "context" "strings" "time" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/executor" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/agentruntime" v1 "github.com/kandev/kandev/pkg/api/v1" ) // Runtime abstracts the agent execution environment (Docker, Standalone, K8s, SSH, etc.) // Each runtime is responsible for creating and managing agentctl instances. // Agent subprocess launching is handled separately via agentctl client methods. type ExecutorBackend interface { // Name returns the runtime identifier (e.g., "docker", "standalone", "k8s") Name() executor.Name // HealthCheck verifies the runtime is available and operational HealthCheck(ctx context.Context) error // CreateInstance creates a new agentctl instance for a task. // This starts the agentctl process/container with workspace access (shell, git, files). // Agent subprocess is NOT started - use agentctl.Client.ConfigureAgent() + Start(). CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) // StopInstance stops an agentctl instance. StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error // RecoverInstances discovers and recovers instances that were running before a restart. // Returns recovered instances that can be re-tracked by the manager. RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) // GetInteractiveRunner returns the interactive runner for passthrough mode. // May return nil if the runtime doesn't support passthrough mode. GetInteractiveRunner() *process.InteractiveRunner // RequiresCloneURL reports whether this executor needs a git clone URL // instead of a local filesystem path for repository access. RequiresCloneURL() bool // ShouldApplyPreferredShell reports whether the user's preferred shell // should be injected into the agent environment. ShouldApplyPreferredShell() bool // IsAlwaysResumable reports whether sessions on this executor can be // resumed even without an explicit resume token. IsAlwaysResumable() bool } // McpServerConfig holds configuration for an MCP server. // Type alias for agentctl.McpServerConfig to avoid conversion boilerplate. type McpServerConfig = agentctl.McpServerConfig // Metadata keys for runtime-specific configuration const ( MetadataKeyMainRepoGitDir = "main_repo_git_dir" MetadataKeyWorktreeID = "worktree_id" MetadataKeyWorktreeBranch = "worktree_branch" // Remote executor metadata keys MetadataKeyRepositoryPath = "repository_path" MetadataKeySetupScript = "setup_script" MetadataKeyCleanupScript = "cleanup_script" MetadataKeyRepoSetupScript = "repository_setup_script" MetadataKeyBaseBranch = "base_branch" // MetadataKeyBaseBranches stores a map[string]string (RepositoryName → // base branch ref) for per-repo diff-stat resolution inside agentctl. // The empty key "" applies to the root / single-repo tracker. MetadataKeyBaseBranches = "base_branches" MetadataKeyIsRemote = "is_remote" MetadataKeyRemoteAuthHome = "remote_auth_target_home" MetadataKeyGitUserName = "git_user_name" MetadataKeyGitUserEmail = "git_user_email" MetadataKeyImageTagOverride = "image_tag_override" MetadataKeyContainerID = "container_id" MetadataKeySpriteName = "sprite_name" MetadataKeySpriteState = "sprite_state" MetadataKeySpriteCreatedAt = "sprite_created_at" MetadataKeyLocalPort = "local_port" // MetadataKeyModelOverride holds a user-requested model that overrides the // agent profile's configured model on the next launch. Set by SetSessionModel // for passthrough sessions, which restart the PTY to apply the new --model. MetadataKeyModelOverride = "model_override" // Office metadata keys MetadataKeySkillManifestJSON = "skill_manifest_json" // SSH runtime metadata keys (per-session, except SSHWorkdirRoot which is per-profile). MetadataKeySSHHostAlias = "ssh_host_alias" MetadataKeySSHHost = "ssh_host" MetadataKeySSHPort = "ssh_port" MetadataKeySSHUser = "ssh_user" MetadataKeySSHHostFingerprint = "ssh_host_fingerprint" MetadataKeySSHRemoteTaskDir = "ssh_remote_task_dir" MetadataKeySSHRemoteSessionDir = "ssh_remote_session_dir" MetadataKeySSHRemoteAgentctlPort = "ssh_remote_agentctl_port" MetadataKeySSHRemoteAgentctlPID = "ssh_remote_agentctl_pid" MetadataKeySSHLocalForwardPort = "ssh_local_forward_port" MetadataKeySSHRemoteAgentctlURL = "ssh_remote_agentctl_url" MetadataKeySSHWorkdirRoot = "ssh_workdir_root" MetadataKeySSHProxyJump = "ssh_proxy_jump" MetadataKeySSHIdentitySource = "ssh_identity_source" MetadataKeySSHIdentityFile = "ssh_identity_file" // MetadataKeySSHShell names the login shell used when running commands // over SSH on the remote (probe, agentctl launch, install, setup // scripts). Empty / unset falls back to "bash" at runtime — see // WrapLoginShell. Stored per-profile so different profiles on the same // host can use different shells; flows into req.Metadata via the // standard executor-config merge in buildLaunchMetadata. MetadataKeySSHShell = "ssh_shell" ) // persistentMetadataKeys lists metadata keys carried forward from a previous // ExecutorRunning record when a session is resumed. Keys not listed here // (e.g., task_description, session_id) are treated as launch-time-only and // are NOT copied on resume. var persistentMetadataKeys = map[string]bool{ // Sprites runtime MetadataKeySpriteName: true, MetadataKeySpriteState: true, MetadataKeySpriteCreatedAt: true, MetadataKeyLocalPort: true, // SSH runtime MetadataKeySSHHost: true, MetadataKeySSHPort: true, MetadataKeySSHUser: true, MetadataKeySSHHostFingerprint: true, MetadataKeySSHRemoteTaskDir: true, MetadataKeySSHRemoteSessionDir: true, MetadataKeySSHRemoteAgentctlPort: true, MetadataKeySSHRemoteAgentctlPID: true, MetadataKeySSHLocalForwardPort: true, MetadataKeySSHRemoteAgentctlURL: true, MetadataKeySSHWorkdirRoot: true, MetadataKeySSHProxyJump: true, MetadataKeySSHIdentitySource: true, MetadataKeySSHIdentityFile: true, MetadataKeySSHShell: true, // Executor type marker MetadataKeyIsRemote: true, // Executor profile / auth config MetadataKeyCleanupScript: true, MetadataKeyRepoSetupScript: true, MetadataKeyRemoteAuthHome: true, MetadataKeyGitUserName: true, MetadataKeyGitUserEmail: true, "remote_credentials": true, "remote_auth_secrets": true, "executor_mcp_policy": true, "sprites_network_policy_rules": true, "executor_profile_id": true, MetadataKeyImageTagOverride: true, MetadataKeyContainerID: true, MetadataKeyWorktreeBranch: true, } // persistentMetadataPrefixes lists key prefixes that should persist. // Any key starting with one of these prefixes is carried forward. var persistentMetadataPrefixes = []string{ "env_secret_id_", // Secret store UUIDs for profile env vars } // sessionScopedMetadataKeys lists metadata keys that point to per-session // runtime resources (process PIDs, allocated ports, session directories on // the remote). These keys ARE persisted across a SAME-session resume — that's // how a backend restart reattaches to a still-running remote agent — but they // MUST NOT be carried across SIBLING sessions on the same task. If they were, // the second session would try to attach to the first session's agentctl // process and end up sharing its ACP session and instance port. var sessionScopedMetadataKeys = map[string]bool{ MetadataKeySSHRemoteSessionDir: true, MetadataKeySSHRemoteAgentctlPort: true, MetadataKeySSHRemoteAgentctlPID: true, MetadataKeySSHLocalForwardPort: true, MetadataKeySSHRemoteAgentctlURL: true, } // ShouldPersistMetadataKey returns true if the given metadata key should // be carried forward when resuming a session from an ExecutorRunning record. func ShouldPersistMetadataKey(key string) bool { if persistentMetadataKeys[key] { return true } for _, prefix := range persistentMetadataPrefixes { if strings.HasPrefix(key, prefix) { return true } } return false } // IsSessionScopedMetadataKey reports whether key references per-session // runtime resources that must not leak across sibling sessions on the same // task environment. func IsSessionScopedMetadataKey(key string) bool { return sessionScopedMetadataKeys[key] } // sshWorkspaceFallbackKeys are the STABLE SSH executor-config keys projected // into workspace metadata as a fallback for terminal / workspace-restore when // no live ExecutorRunning record exists. This is deliberately a connection + // per-profile allowlist and MUST NOT include the session-scoped runtime keys // (remote session dir, agentctl port/PID/URL, local forward port) — projecting // a stale one would make the lifecycle manager try to reattach to a dead remote // agentctl instance instead of creating a fresh one. It mirrors // trustedExecutorConfigKeys (the connection-routing set targetFromMetadata // reads) plus the two per-profile keys the terminal path needs (workdir root, // login shell). Notably it includes ssh_host_alias so alias-only executors // (host read from ~/.ssh/config) survive restore. var sshWorkspaceFallbackKeys = map[string]bool{ MetadataKeySSHHost: true, MetadataKeySSHHostAlias: true, MetadataKeySSHPort: true, MetadataKeySSHUser: true, MetadataKeySSHHostFingerprint: true, MetadataKeySSHIdentitySource: true, MetadataKeySSHIdentityFile: true, MetadataKeySSHProxyJump: true, MetadataKeySSHWorkdirRoot: true, MetadataKeySSHShell: true, } // FilterSSHWorkspaceFallbackConfig returns the subset of a stored SSH executor // config that is safe to project into workspace metadata as a fallback for the // terminal / workspace-restore path. Only stable connection + per-profile keys // are copied (see sshWorkspaceFallbackKeys); session-scoped runtime keys are // intentionally dropped. Returns nil when nothing matches. func FilterSSHWorkspaceFallbackConfig(config map[string]string) map[string]interface{} { if len(config) == 0 { return nil } filtered := make(map[string]interface{}) for k, v := range config { if sshWorkspaceFallbackKeys[k] { filtered[k] = v } } if len(filtered) == 0 { return nil } return filtered } // FilterPersistentMetadata returns a copy of src containing only keys that // should be carried forward across session resumes. Returns nil if no keys match. func FilterPersistentMetadata(src map[string]interface{}) map[string]interface{} { if src == nil { return nil } filtered := make(map[string]interface{}) for k, v := range src { if ShouldPersistMetadataKey(k) { filtered[k] = v } } if len(filtered) == 0 { return nil } return filtered } // RemoteStatus describes runtime health/details for remote executors. // It is intentionally generic so each executor can include extra details in Details. type RemoteStatus struct { RuntimeName agentruntime.Runtime `json:"runtime_name"` RemoteName string `json:"remote_name,omitempty"` State string `json:"state,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` LastCheckedAt time.Time `json:"last_checked_at"` ErrorMessage string `json:"error_message,omitempty"` Details map[string]interface{} `json:"details,omitempty"` } // RemoteSessionResumer is an optional capability for remote runtimes that need // explicit reattachment logic on resume (e.g. reconnect to an existing sprite). type RemoteSessionResumer interface { ResumeRemoteInstance(ctx context.Context, req *ExecutorCreateRequest) error } // RemoteStatusProvider is an optional capability for runtimes that can expose // remote environment status for UX (cloud icon tooltip, degraded state, etc.). type RemoteStatusProvider interface { GetRemoteStatus(ctx context.Context, instance *ExecutorInstance) (*RemoteStatus, error) } // ExecutorCreateRequest contains parameters for creating an agentctl instance. type ExecutorCreateRequest struct { InstanceID string TaskID string TaskTitle string SessionID string TaskEnvironmentID string // Env this execution belongs to (shared across sessions in same task) AgentProfileID string WorkspacePath string Protocol string Env map[string]string AutoApprovePermissions bool // AutoApprovePermissionsOverride is set when a resolved profile explicitly // selected the auto-approve value. Nil preserves agentctl defaults/env fallback. AutoApprovePermissionsOverride *bool Metadata map[string]interface{} McpServers []McpServerConfig AgentConfig agents.Agent // Agent type info needed by runtimes PreviousExecutionID string // Non-empty when reconnecting to a previous execution McpMode string // MCP tool mode: "task" (default), "config", or "office" AuthToken string // Previously handshaken agentctl token for reconnects BootstrapNonce string // Stored nonce for re-handshake after container restart // OnProgress is an optional callback for streaming preparation progress. // Executors that perform multi-step setup (e.g. Sprites, remote Docker) can // call this to report real-time progress to the frontend. OnProgress PrepareProgressCallback } // ExecutorInstance represents an agentctl instance created by a runtime. // This is returned by the runtime and contains enough info to build an AgentExecution. type ExecutorInstance struct { // Core identifiers InstanceID string TaskID string SessionID string // Runtime name (e.g., "docker", "standalone") - set by the runtime that created this instance RuntimeName agentruntime.Runtime // Agentctl client for communicating with this instance Client *agentctl.Client // Runtime-specific identifiers (only one set is populated) ContainerID string // Docker ContainerIP string // Docker StandaloneInstanceID string // Standalone StandalonePort int // Standalone // Common fields WorkspacePath string Metadata map[string]interface{} StopReason string AgentStopFailed bool // AuthToken is the agentctl auth token retrieved via handshake. // Populated by Docker executor for encrypted storage in SecretStore. // Empty for standalone (launcher-owned token wired via cfg.Agent.StandaloneAuthToken) // and Sprites (no agentctl auth). AuthToken string // BootstrapNonce is the one-time nonce injected into Docker container env. // It is persisted so a restarted container can complete a fresh handshake // against the newly started agentctl process. BootstrapNonce string } // ToAgentExecution converts a ExecutorInstance to an AgentExecution. func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentExecution { metadata := req.Metadata if metadata == nil { metadata = make(map[string]interface{}) } // Merge runtime metadata for k, v := range ri.Metadata { metadata[k] = v } workspacePath := ri.WorkspacePath if workspacePath == "" { workspacePath = req.WorkspacePath } var historyEnabled bool var agentID string if req.AgentConfig != nil { agentID = req.AgentConfig.ID() if rt := req.AgentConfig.Runtime(); rt != nil { historyEnabled = rt.SessionConfig.HistoryContextInjection } } return &AgentExecution{ ID: ri.InstanceID, TaskID: req.TaskID, SessionID: req.SessionID, TaskEnvironmentID: req.TaskEnvironmentID, AgentProfileID: req.AgentProfileID, AgentID: agentID, ContainerID: ri.ContainerID, ContainerIP: ri.ContainerIP, WorkspacePath: workspacePath, RuntimeName: ri.RuntimeName, Status: v1.AgentStatusRunning, StartedAt: time.Now(), Metadata: metadata, agentctl: ri.Client, standaloneInstanceID: ri.StandaloneInstanceID, standalonePort: ri.StandalonePort, historyEnabled: historyEnabled, promptDoneCh: make(chan PromptCompletionSignal, 1), } }