2600 lines
100 KiB
Go
2600 lines
100 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/executor"
|
|
kubeexecutor "github.com/kandev/kandev/internal/agent/kubernetes"
|
|
"github.com/kandev/kandev/internal/agent/runtime/activity"
|
|
"github.com/kandev/kandev/internal/agent/settings/cliflags"
|
|
"github.com/kandev/kandev/internal/agentruntime"
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/gitconfigenv"
|
|
"github.com/kandev/kandev/internal/mcp/plugintools"
|
|
storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/worktree"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
const legacyExecutorTypeLocalPC = "local_pc"
|
|
|
|
const registeredLaunchRollbackRetries = 3
|
|
|
|
var registeredLaunchRollbackRetryDelays = [...]time.Duration{
|
|
100 * time.Millisecond,
|
|
500 * time.Millisecond,
|
|
2 * time.Second,
|
|
}
|
|
|
|
var errTaskCleanupActive = errors.New("task cleanup is active")
|
|
|
|
// resolveAgentProfile resolves the agent profile and returns the agent type name and profile info.
|
|
func (m *Manager) resolveAgentProfile(ctx context.Context, req *LaunchRequest) (string, *AgentProfileInfo, error) {
|
|
profileID := executionProfileID(req)
|
|
if m.profileResolver == nil {
|
|
// Fallback: treat AgentProfileID as agent type directly (for backward compat)
|
|
m.logger.Warn("no profile resolver configured, using profile ID as agent type",
|
|
zap.String("agent_type", profileID))
|
|
return profileID, nil, nil
|
|
}
|
|
profileInfo, err := m.profileResolver.ResolveProfile(ctx, profileID)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to resolve agent profile: %w", err)
|
|
}
|
|
// Legacy model-only routes still use overlays until their persisted config
|
|
// is migrated. A concrete execution profile is authoritative and must not
|
|
// be mixed with fields from another provider.
|
|
if !hasConcreteRouteExecutionProfile(req) {
|
|
applyRouteOverrideToProfile(profileInfo, req)
|
|
}
|
|
m.logger.Debug("resolved agent profile",
|
|
zap.String("profile_id", profileID),
|
|
zap.String("agent_name", profileInfo.AgentName),
|
|
zap.String("agent_type", profileInfo.AgentName))
|
|
return profileInfo.AgentName, profileInfo, nil
|
|
}
|
|
|
|
func executionProfileID(req *LaunchRequest) string {
|
|
if req == nil {
|
|
return ""
|
|
}
|
|
if req.ExecutionProfileID != "" {
|
|
return req.ExecutionProfileID
|
|
}
|
|
return req.AgentProfileID
|
|
}
|
|
|
|
func hasConcreteRouteExecutionProfile(req *LaunchRequest) bool {
|
|
return req != nil && req.RouteOverride != nil && req.RouteOverride.ExecutionProfileID != ""
|
|
}
|
|
|
|
// appendRouteOverrideFlags preserves legacy model-only routing overlays.
|
|
// Concrete execution profiles own their complete CLI configuration.
|
|
func appendRouteOverrideFlags(tokens []string, req *LaunchRequest) []string {
|
|
if req == nil || hasConcreteRouteExecutionProfile(req) || req.RouteOverride == nil || len(req.RouteOverride.Flags) == 0 {
|
|
return tokens
|
|
}
|
|
out := make([]string, 0, len(tokens)+len(req.RouteOverride.Flags))
|
|
out = append(out, tokens...)
|
|
out = append(out, req.RouteOverride.Flags...)
|
|
return out
|
|
}
|
|
|
|
// applyRouteOverrideToProfile mutates profileInfo in-place when the
|
|
// request carries a RouteOverride. Empty fields on the override are
|
|
// preserved on the profile — the override only replaces explicit values.
|
|
// Mode override is applied unconditionally because routing's mode
|
|
// belongs to the provider, not the base profile.
|
|
func applyRouteOverrideToProfile(profile *AgentProfileInfo, req *LaunchRequest) {
|
|
if profile == nil || req == nil || req.RouteOverride == nil {
|
|
return
|
|
}
|
|
ov := req.RouteOverride
|
|
if ov.ProviderID != "" {
|
|
profile.AgentName = ov.ProviderID
|
|
}
|
|
if ov.Model != "" {
|
|
profile.Model = ov.Model
|
|
}
|
|
profile.Mode = ov.Mode
|
|
}
|
|
|
|
// trustedExecutorConfigKeys are the metadata keys whose value MUST come from
|
|
// the configured executor record — never from request-supplied metadata —
|
|
// because they steer the connection (host, fingerprint, identity). Letting a
|
|
// task override them would allow pivoting an SSH launch to a different host
|
|
// or bypassing the pinned host-key.
|
|
var trustedExecutorConfigKeys = map[string]bool{
|
|
MetadataKeySSHHost: true,
|
|
MetadataKeySSHHostAlias: true,
|
|
MetadataKeySSHPort: true,
|
|
MetadataKeySSHUser: true,
|
|
MetadataKeySSHHostFingerprint: true,
|
|
MetadataKeySSHIdentitySource: true,
|
|
MetadataKeySSHIdentityFile: true,
|
|
MetadataKeySSHProxyJump: true,
|
|
MetadataKeyKubernetesAuthMode: true,
|
|
MetadataKeyKubernetesKubeconfigPath: true,
|
|
MetadataKeyKubernetesKubeContext: true,
|
|
MetadataKeyKubernetesConfigNamespace: true,
|
|
MetadataKeyKubernetesRequestTimeoutSeconds: true,
|
|
}
|
|
|
|
func isTrustedExecutorConfigKey(k string) bool { return trustedExecutorConfigKeys[k] }
|
|
|
|
var kubernetesProfileMetadataKeys = [...]string{
|
|
MetadataKeyKubernetesProfilePlatform,
|
|
MetadataKeyKubernetesProfileMainContainer,
|
|
MetadataKeyKubernetesPodTemplateYAML,
|
|
MetadataKeyKubernetesWorkspaceMode,
|
|
MetadataKeyKubernetesWorkspaceSize,
|
|
MetadataKeyKubernetesWorkspaceStorageClass,
|
|
MetadataKeyKubernetesWorkspaceAccessModes,
|
|
MetadataKeyKubernetesWorkspaceClaimName,
|
|
}
|
|
|
|
var kubernetesConnectionMetadataKeys = [...]string{
|
|
MetadataKeyKubernetesAuthMode,
|
|
MetadataKeyKubernetesKubeconfigPath,
|
|
MetadataKeyKubernetesKubeContext,
|
|
MetadataKeyKubernetesConfigNamespace,
|
|
MetadataKeyKubernetesRequestTimeoutSeconds,
|
|
}
|
|
|
|
// applyAuthoritativeKubernetesProfileConfig replaces fresh-launch Pod and
|
|
// workspace inputs with the stored executor-profile value. A retained launch
|
|
// keeps its immutable lifecycle-produced snapshot so profile edits or deletion
|
|
// cannot change or prevent recovery of the recorded workload.
|
|
func (m *Manager) applyAuthoritativeKubernetesProfileConfig(
|
|
ctx context.Context,
|
|
req *LaunchRequest,
|
|
metadata map[string]interface{},
|
|
) error {
|
|
if req == nil || req.ExecutorType != string(models.ExecutorTypeKubernetes) {
|
|
return nil
|
|
}
|
|
if req.PreviousExecutionID != "" && hasCompleteKubernetesRecordedResumeMetadata(metadata) {
|
|
return nil
|
|
}
|
|
if m.executorProfileReader == nil {
|
|
return errors.New("load Kubernetes executor profile: profile reader is not configured")
|
|
}
|
|
profileID := strings.TrimSpace(getMetadataString(metadata, MetadataKeyExecutorProfileID))
|
|
if profileID == "" {
|
|
return errors.New("load Kubernetes executor profile: profile ID is missing")
|
|
}
|
|
profile, err := m.executorProfileReader.GetExecutorProfile(ctx, profileID)
|
|
if err != nil {
|
|
return fmt.Errorf("load Kubernetes executor profile %q: %w", profileID, err)
|
|
}
|
|
if profile == nil {
|
|
return fmt.Errorf("load Kubernetes executor profile %q: not found", profileID)
|
|
}
|
|
if strings.TrimSpace(profile.ExecutorID) == "" {
|
|
return fmt.Errorf("load Kubernetes executor profile %q: executor ID is missing", profileID)
|
|
}
|
|
executorID := strings.TrimSpace(getMetadataString(metadata, "executor_id"))
|
|
if executorID != "" && executorID != profile.ExecutorID {
|
|
return fmt.Errorf(
|
|
"load Kubernetes executor profile %q: belongs to executor %q, not %q",
|
|
profileID, profile.ExecutorID, executorID,
|
|
)
|
|
}
|
|
typedProfile, err := kubeexecutor.ParseProfileConfig(profile.Config)
|
|
if err != nil {
|
|
return fmt.Errorf("load Kubernetes executor profile %q: invalid config: %w", profileID, err)
|
|
}
|
|
metadata["executor_id"] = profile.ExecutorID
|
|
if err := applyKubernetesProfileConfigToMetadata(metadata, typedProfile); err != nil {
|
|
return fmt.Errorf("load Kubernetes executor profile %q: canonicalize config: %w", profileID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyKubernetesProfileConfigToMetadata(
|
|
metadata map[string]interface{},
|
|
profile kubeexecutor.ProfileConfig,
|
|
) error {
|
|
accessModes := ""
|
|
if len(profile.Workspace.AccessModes) > 0 {
|
|
encoded, err := json.Marshal(profile.Workspace.AccessModes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
accessModes = string(encoded)
|
|
}
|
|
values := map[string]string{
|
|
MetadataKeyKubernetesProfilePlatform: string(profile.Platform),
|
|
MetadataKeyKubernetesProfileMainContainer: profile.MainContainer,
|
|
MetadataKeyKubernetesPodTemplateYAML: profile.PodTemplateYAML,
|
|
MetadataKeyKubernetesWorkspaceMode: string(profile.Workspace.Mode),
|
|
MetadataKeyKubernetesWorkspaceSize: profile.Workspace.Size,
|
|
MetadataKeyKubernetesWorkspaceStorageClass: profile.Workspace.StorageClass,
|
|
MetadataKeyKubernetesWorkspaceAccessModes: accessModes,
|
|
MetadataKeyKubernetesWorkspaceClaimName: profile.Workspace.ClaimName,
|
|
}
|
|
for _, key := range kubernetesProfileMetadataKeys {
|
|
metadata[key] = values[key]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasCompleteKubernetesRecordedResumeMetadata(metadata map[string]interface{}) bool {
|
|
required := []string{
|
|
MetadataKeyKubernetesNamespace,
|
|
MetadataKeyKubernetesPodName,
|
|
MetadataKeyKubernetesPodUID,
|
|
MetadataKeyKubernetesMainContainer,
|
|
MetadataKeyKubernetesRuntimeWorkspaceMode,
|
|
MetadataKeyKubernetesAgentctlRemotePort,
|
|
MetadataKeyKubernetesAgentctlInstanceID,
|
|
MetadataKeyKubernetesResourceExecutorID,
|
|
MetadataKeyKubernetesResourceProfileID,
|
|
MetadataKeyKubernetesResourceInstanceID,
|
|
MetadataKeyKubernetesResourceTaskID,
|
|
MetadataKeyKubernetesResourceSessionID,
|
|
MetadataKeyKubernetesResourceEnvironmentID,
|
|
MetadataKeyKubernetesExecutorConfigHash,
|
|
MetadataKeyKubernetesProfileConfigHash,
|
|
MetadataKeyKubernetesTemplateHash,
|
|
MetadataKeyKubernetesProfileSnapshot,
|
|
}
|
|
for _, key := range required {
|
|
if getMetadataString(metadata, key) == "" {
|
|
return false
|
|
}
|
|
}
|
|
switch getMetadataString(metadata, MetadataKeyKubernetesRuntimeWorkspaceMode) {
|
|
case string(kubeexecutor.WorkspaceModeEmptyDir):
|
|
return true
|
|
case string(kubeexecutor.WorkspaceModeManagedPVC):
|
|
return getMetadataString(metadata, MetadataKeyKubernetesPVCName) != "" &&
|
|
getMetadataString(metadata, MetadataKeyKubernetesPVCUID) != "" &&
|
|
getMetadataBool(metadata, MetadataKeyKubernetesPVCCreated)
|
|
case string(kubeexecutor.WorkspaceModeExistingClaim):
|
|
return getMetadataString(metadata, MetadataKeyKubernetesPVCName) != "" &&
|
|
getMetadataString(metadata, MetadataKeyKubernetesPVCUID) != ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// buildLaunchMetadata builds runtime metadata for the Launch request.
|
|
//
|
|
// Per-executor config (host, fingerprint, ssh identity, …) is the trusted
|
|
// source for connection-routing decisions, so it overrides any same-key
|
|
// values the caller passed in req.Metadata. Other keys (per-task settings
|
|
// like setup_script, base_branch, repo_setup_script, etc.) keep the caller's
|
|
// value when present.
|
|
func buildLaunchMetadata(req *LaunchRequest, mainRepoGitDir, worktreeID, worktreeBranch string) map[string]interface{} {
|
|
metadata := make(map[string]interface{})
|
|
for k, v := range req.Metadata {
|
|
metadata[k] = v
|
|
}
|
|
putPrimaryCheckoutOptions(metadata, req)
|
|
for k, v := range req.ExecutorConfig {
|
|
if isTrustedExecutorConfigKey(k) {
|
|
// Executor config wins for connection-routing keys so a malicious
|
|
// or buggy task metadata payload can't swap out the SSH host /
|
|
// pinned fingerprint and pivot the launch to a different target.
|
|
metadata[k] = v
|
|
continue
|
|
}
|
|
if _, exists := metadata[k]; !exists {
|
|
metadata[k] = v
|
|
}
|
|
}
|
|
if req.ExecutorType == string(models.ExecutorTypeKubernetes) {
|
|
for _, key := range kubernetesConnectionMetadataKeys {
|
|
metadata[key] = req.ExecutorConfig[key]
|
|
}
|
|
}
|
|
if mainRepoGitDir != "" {
|
|
metadata[MetadataKeyMainRepoGitDir] = mainRepoGitDir
|
|
}
|
|
if worktreeID != "" {
|
|
metadata[MetadataKeyWorktreeID] = worktreeID
|
|
}
|
|
if worktreeBranch != "" {
|
|
metadata[MetadataKeyWorktreeBranch] = worktreeBranch
|
|
}
|
|
// Pass repo info for remote executors (Sprites, remote docker, etc.)
|
|
if req.RepositoryPath != "" {
|
|
metadata[MetadataKeyRepositoryPath] = req.RepositoryPath
|
|
}
|
|
if req.SetupScript != "" {
|
|
metadata[MetadataKeySetupScript] = req.SetupScript
|
|
}
|
|
if req.BaseBranch != "" {
|
|
metadata[MetadataKeyBaseBranch] = req.BaseBranch
|
|
}
|
|
if branches := collectBaseBranches(req); len(branches) > 0 {
|
|
metadata[MetadataKeyBaseBranches] = branches
|
|
}
|
|
setSelectedCheckoutMetadata(req, metadata)
|
|
return metadata
|
|
}
|
|
|
|
// collectComparisonTargets projects the validated per-repository comparison
|
|
// targets into the same workspace-subpath keys used by base branches. The
|
|
// target remains credential-free and is revalidated at the runtime boundary.
|
|
func collectComparisonTargets(req *LaunchRequest) (map[string]models.ComparisonTarget, error) {
|
|
if req == nil {
|
|
return nil, nil
|
|
}
|
|
specs := req.RepoSpecs()
|
|
if len(specs) == 0 {
|
|
if req.ComparisonTarget == nil {
|
|
return nil, nil
|
|
}
|
|
if err := req.ComparisonTarget.Validate(); err != nil {
|
|
return nil, fmt.Errorf("validate comparison target: %w", err)
|
|
}
|
|
return map[string]models.ComparisonTarget{"": *req.ComparisonTarget}, nil
|
|
}
|
|
targets := make(map[string]models.ComparisonTarget)
|
|
for index, spec := range specs {
|
|
if spec.ComparisonTarget == nil {
|
|
continue
|
|
}
|
|
if err := spec.ComparisonTarget.Validate(); err != nil {
|
|
return nil, fmt.Errorf("validate comparison target for repository %q: %w", spec.RepoName, err)
|
|
}
|
|
key := ""
|
|
if index > 0 {
|
|
key = baseBranchMetadataKey(spec)
|
|
}
|
|
if existing, ok := targets[key]; ok && !existing.Equal(*spec.ComparisonTarget) {
|
|
return nil, fmt.Errorf("multiple comparison targets map to workspace repository %q", key)
|
|
}
|
|
targets[key] = *spec.ComparisonTarget
|
|
}
|
|
if len(targets) == 0 {
|
|
return nil, nil
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func comparisonTargetsFromWorkspaceRepositories(specs []WorkspaceRepositorySpec) (map[string]models.ComparisonTarget, error) {
|
|
if len(specs) == 0 {
|
|
return nil, nil
|
|
}
|
|
targets := make(map[string]models.ComparisonTarget)
|
|
for index, spec := range specs {
|
|
if spec.ComparisonTarget == nil {
|
|
continue
|
|
}
|
|
if err := spec.ComparisonTarget.Validate(); err != nil {
|
|
return nil, fmt.Errorf("validate comparison target for repository %q: %w", spec.RepoName, err)
|
|
}
|
|
key := ""
|
|
if index > 0 {
|
|
key = baseBranchMetadataKey(RepoLaunchSpec{
|
|
RepoName: spec.RepoName,
|
|
BranchSlug: spec.BranchSlug,
|
|
})
|
|
}
|
|
if existing, ok := targets[key]; ok && !existing.Equal(*spec.ComparisonTarget) {
|
|
return nil, fmt.Errorf("comparison target collision for workspace repository %q", key)
|
|
}
|
|
targets[key] = *spec.ComparisonTarget
|
|
}
|
|
if len(targets) == 0 {
|
|
return nil, nil
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
// collectRemoteContributions projects the validated per-repository bindings
|
|
// into the workspace-subpath keys understood by agentctl. The first repository
|
|
// owns the workspace root; sibling destinations use the same deterministic key
|
|
// as base-branch and workspace materialization projection.
|
|
func collectRemoteContributions(req *LaunchRequest) (map[string]models.RemoteContribution, error) {
|
|
if req == nil {
|
|
return nil, nil
|
|
}
|
|
specs := req.RepoSpecs()
|
|
if len(specs) == 0 {
|
|
if req.RemoteContribution == nil {
|
|
return nil, nil
|
|
}
|
|
if err := req.RemoteContribution.Validate(); err != nil {
|
|
return nil, fmt.Errorf("validate remote contribution: %w", err)
|
|
}
|
|
return map[string]models.RemoteContribution{"": *req.RemoteContribution}, nil
|
|
}
|
|
bindings := make(map[string]models.RemoteContribution)
|
|
for index, spec := range specs {
|
|
if spec.RemoteContribution == nil {
|
|
continue
|
|
}
|
|
if err := spec.RemoteContribution.Validate(); err != nil {
|
|
return nil, fmt.Errorf("validate remote contribution for repository %q: %w", spec.RepoName, err)
|
|
}
|
|
key := ""
|
|
if index > 0 {
|
|
key = baseBranchMetadataKey(spec)
|
|
}
|
|
if existing, ok := bindings[key]; ok && existing.CanonicalURL != spec.RemoteContribution.CanonicalURL {
|
|
return nil, fmt.Errorf("multiple remote contributions target workspace repository %q", key)
|
|
}
|
|
bindings[key] = *spec.RemoteContribution
|
|
}
|
|
if len(bindings) == 0 {
|
|
return nil, nil
|
|
}
|
|
return bindings, nil
|
|
}
|
|
|
|
// collectContributionDestinations projects server-authored managed fork
|
|
// destinations using the same workspace-subpath keys as remote contributions.
|
|
func collectContributionDestinations(req *LaunchRequest) (map[string]models.ContributionDestination, error) {
|
|
if req == nil {
|
|
return nil, nil
|
|
}
|
|
specs := req.RepoSpecs()
|
|
if len(specs) == 0 {
|
|
if req.ContributionDestination == nil {
|
|
return nil, nil
|
|
}
|
|
if err := req.ContributionDestination.Validate(); err != nil {
|
|
return nil, fmt.Errorf("validate contribution destination: %w", err)
|
|
}
|
|
return map[string]models.ContributionDestination{"": *req.ContributionDestination}, nil
|
|
}
|
|
destinations := make(map[string]models.ContributionDestination)
|
|
for index, spec := range specs {
|
|
if spec.ContributionDestination == nil {
|
|
continue
|
|
}
|
|
if err := spec.ContributionDestination.Validate(); err != nil {
|
|
return nil, fmt.Errorf("validate contribution destination for repository %q: %w", spec.RepoName, err)
|
|
}
|
|
key := ""
|
|
if index > 0 {
|
|
key = baseBranchMetadataKey(spec)
|
|
}
|
|
if existing, ok := destinations[key]; ok {
|
|
if !sameContributionDestinationTarget(existing, *spec.ContributionDestination) {
|
|
return nil, fmt.Errorf("multiple contribution destinations target workspace repository %q", key)
|
|
}
|
|
continue
|
|
}
|
|
destinations[key] = *spec.ContributionDestination
|
|
}
|
|
if len(destinations) == 0 {
|
|
return nil, nil
|
|
}
|
|
return destinations, nil
|
|
}
|
|
|
|
func sameContributionDestinationTarget(left, right models.ContributionDestination) bool {
|
|
return strings.EqualFold(left.TargetRepository.Host, right.TargetRepository.Host) &&
|
|
left.TargetRepository.Path == right.TargetRepository.Path &&
|
|
left.TargetRepository.ProviderID == right.TargetRepository.ProviderID &&
|
|
left.TargetRepository.RemoteURL == right.TargetRepository.RemoteURL
|
|
}
|
|
|
|
// collectBaseBranches builds the per-repo {RepositoryName → base_branch}
|
|
// map that agentctl reads to scope diff stats. Single-repo legacy launches
|
|
// are recorded under the empty key "" so single-repo trackers (which have
|
|
// no repositoryName) still find their value. Repos missing a base_branch
|
|
// are skipped so the existing fallback list applies to them.
|
|
func collectBaseBranches(req *LaunchRequest) map[string]string {
|
|
specs := req.RepoSpecs()
|
|
if len(specs) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(specs)+1)
|
|
for _, spec := range specs {
|
|
if spec.BaseBranch == "" {
|
|
continue
|
|
}
|
|
if key := baseBranchMetadataKey(spec); key != "" {
|
|
out[key] = spec.BaseBranch
|
|
}
|
|
}
|
|
if req.BaseBranch != "" {
|
|
if _, ok := out[""]; !ok {
|
|
out[""] = req.BaseBranch
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func baseBranchMetadataKey(spec RepoLaunchSpec) string {
|
|
repoName := worktree.SanitizeRepoDirName(spec.RepoName)
|
|
if repoName == "" {
|
|
repoName = spec.RepoName
|
|
}
|
|
branchSlug := worktree.SanitizeBranchSlug(spec.BranchSlug)
|
|
if branchSlug == "" {
|
|
return repoName
|
|
}
|
|
return repoName + "-" + branchSlug
|
|
}
|
|
|
|
// agentCommands holds both the display strings and structured argv for an agent execution.
|
|
type agentCommands struct {
|
|
initial string
|
|
continue_ string // continue command for one-shot agents (empty if not applicable)
|
|
args []string
|
|
continueArgs []string
|
|
}
|
|
|
|
func newAgentCommands(args, continueArgs []string) agentCommands {
|
|
return agentCommands{
|
|
initial: strings.Join(args, " "),
|
|
continue_: strings.Join(continueArgs, " "),
|
|
args: args,
|
|
continueArgs: continueArgs,
|
|
}
|
|
}
|
|
|
|
// resolveProfileLaunchTokens resolves the user-configured cli_flags argv tokens
|
|
// and the launcher command_prefix tokens for a profile. Both must be applied on
|
|
// every command build — the initial launch AND fresh restarts (context reset) —
|
|
// or a configured profile could silently relaunch without its wrapper.
|
|
//
|
|
// cli_flags are best-effort: a malformed entry is logged and dropped so a typo
|
|
// doesn't block the task. command_prefix is launcher policy, so it fails closed:
|
|
// a profile that configured a prefix which cannot be resolved returns an error
|
|
// and aborts the launch rather than running the agent unwrapped.
|
|
func (m *Manager) resolveProfileLaunchTokens(profileInfo *AgentProfileInfo) (cliFlagTokens, commandPrefixTokens []string, err error) {
|
|
if profileInfo == nil {
|
|
return nil, nil, nil
|
|
}
|
|
if tokens, resolveErr := cliflags.Resolve(profileInfo.CLIFlags); resolveErr != nil {
|
|
m.logger.Warn("failed to resolve cli_flags for profile, launching without user-configured flags",
|
|
zap.String("profile_id", profileInfo.ProfileID),
|
|
zap.Error(resolveErr))
|
|
} else {
|
|
cliFlagTokens = tokens
|
|
}
|
|
if strings.TrimSpace(profileInfo.CommandPrefix) != "" {
|
|
if validateErr := cliflags.ValidateCommandPrefix(profileInfo.CommandPrefix); validateErr != nil {
|
|
return nil, nil, fmt.Errorf("resolve command_prefix for profile %s: %w",
|
|
profileInfo.ProfileID, validateErr)
|
|
}
|
|
commandPrefixTokens, err = cliflags.Tokenise(profileInfo.CommandPrefix)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve command_prefix for profile %s: %w",
|
|
profileInfo.ProfileID, err)
|
|
}
|
|
}
|
|
return cliFlagTokens, commandPrefixTokens, nil
|
|
}
|
|
|
|
func (m *Manager) buildAgentCommandWithContext(
|
|
ctx context.Context,
|
|
req *LaunchRequest,
|
|
profileInfo *AgentProfileInfo,
|
|
agentConfig agents.Agent,
|
|
preferNative bool,
|
|
) (agentCommands, error) {
|
|
model := ""
|
|
autoApprove := false
|
|
permissionValues := make(map[string]bool)
|
|
if profileInfo != nil {
|
|
model = profileInfo.Model
|
|
autoApprove = profileInfo.AutoApprove
|
|
permissionValues[agents.PermissionKeyAutoApprove] = profileInfo.AutoApprove
|
|
permissionValues["allow_indexing"] = profileInfo.AllowIndexing
|
|
permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions
|
|
}
|
|
cliFlagTokens, commandPrefixTokens, err := m.resolveProfileLaunchTokens(profileInfo)
|
|
if err != nil {
|
|
return agentCommands{}, err
|
|
}
|
|
// Allow model override from request (for dynamic model switching)
|
|
if req.ModelOverride != "" {
|
|
model = req.ModelOverride
|
|
}
|
|
cliFlagTokens = appendRouteOverrideFlags(cliFlagTokens, req)
|
|
runtime := models.ExecutorType(req.ExecutorType).Runtime()
|
|
managedRuntimeVersion, err := m.resolveManagedRuntimeVersion(ctx, runtime, agentConfig)
|
|
if err != nil {
|
|
return agentCommands{}, err
|
|
}
|
|
// Only pass SessionID (for --resume flag) if the agent supports recovery.
|
|
// Agents with CanRecover=false (e.g. Auggie) use history context injection instead.
|
|
sessionID := req.ACPSessionID
|
|
if rt := agentConfig.Runtime(); rt != nil && !rt.SessionConfig.SupportsRecovery() {
|
|
sessionID = ""
|
|
}
|
|
cmdOpts := agents.CommandOptions{
|
|
Model: model,
|
|
SessionID: sessionID,
|
|
AutoApprove: autoApprove,
|
|
PermissionValues: permissionValues,
|
|
CLIFlagTokens: cliFlagTokens,
|
|
CommandPrefixTokens: commandPrefixTokens,
|
|
Runtime: runtime,
|
|
PreferNativeBinary: preferNative,
|
|
ManagedRuntimeVersion: managedRuntimeVersion,
|
|
}
|
|
args := m.commandBuilder.BuildCommandArgs(agentConfig, cmdOpts)
|
|
continueArgs := m.commandBuilder.BuildContinueCommandArgs(agentConfig, cmdOpts)
|
|
if err := validateBuiltAgentCommands(args, continueArgs); err != nil {
|
|
return agentCommands{}, err
|
|
}
|
|
return newAgentCommands(args, continueArgs), nil
|
|
}
|
|
|
|
func (m *Manager) resolveManagedRuntimeVersion(
|
|
ctx context.Context,
|
|
_ agentruntime.Runtime,
|
|
agentConfig agents.Agent,
|
|
) (string, error) {
|
|
managed, ok := agentConfig.(agents.ManagedNPMRuntimeAgent)
|
|
if !ok {
|
|
return "", nil
|
|
}
|
|
spec := managed.ManagedNPMRuntime()
|
|
effectiveVersion := spec.DefaultVersion
|
|
if effectiveVersion == "" {
|
|
// Test and embedded agents may construct a spec literal. PackageSpec
|
|
// still resolves a known built-in package's reviewed default.
|
|
packageSpec := spec.PackageSpec("")
|
|
if packageSpec != spec.Package {
|
|
effectiveVersion = strings.TrimPrefix(packageSpec, spec.Package+"@")
|
|
}
|
|
}
|
|
if m.managedRuntimeSelections == nil {
|
|
return effectiveVersion, nil
|
|
}
|
|
selection, found, err := m.managedRuntimeSelections.Get(ctx, agentConfig.ID(), spec.Package)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve active managed runtime version for %s: %w", agentConfig.ID(), err)
|
|
}
|
|
if !found || selection.Package != spec.Package {
|
|
return effectiveVersion, nil
|
|
}
|
|
return selection.Version, nil
|
|
}
|
|
|
|
func validateBuiltAgentCommands(args, continueArgs []string) error {
|
|
if err := cliflags.ValidateCommandArgs(args); err != nil {
|
|
return fmt.Errorf("validate agent command: %w", err)
|
|
}
|
|
if continueArgs != nil {
|
|
if err := cliflags.ValidateCommandArgs(continueArgs); err != nil {
|
|
return fmt.Errorf("validate continue command: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// launchResolveWorkspacePath resolves the effective workspace path for non-worktree executors.
|
|
// For worktree executors, workspace resolution is handled by the WorktreePreparer.
|
|
// For tasks without repositories, creates a workspace directory in ~/.kandev/quick-chat/.
|
|
// Returns workspacePath, mainRepoGitDir, worktreeID, worktreeBranch.
|
|
// resolveResumeWorktreePath resolves workspace path for worktree resume using the provider.
|
|
func (m *Manager) resolveResumeWorktreePath(ctx context.Context, req *LaunchRequest) (string, string, string, string) {
|
|
ws := m.resolveWorkspaceFromProvider(ctx, req)
|
|
if ws == "" {
|
|
m.logger.Warn("could not resolve workspace path for worktree resume",
|
|
zap.String("session_id", req.SessionID))
|
|
return "", "", "", ""
|
|
}
|
|
var mainRepoGitDir string
|
|
if req.RepositoryPath != "" {
|
|
mainRepoGitDir = filepath.Join(req.RepositoryPath, ".git")
|
|
}
|
|
return ws, mainRepoGitDir, req.WorktreeID, ""
|
|
}
|
|
|
|
// resolveWorkspaceFromProvider looks up the workspace path from the info provider.
|
|
func (m *Manager) resolveWorkspaceFromProvider(ctx context.Context, req *LaunchRequest) string {
|
|
if m.workspaceInfoProvider == nil {
|
|
return ""
|
|
}
|
|
info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, req.TaskID, req.SessionID)
|
|
if err != nil || info.WorkspacePath == "" {
|
|
return ""
|
|
}
|
|
m.logger.Debug("resolved workspace from provider for resume",
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("path", info.WorkspacePath))
|
|
return info.WorkspacePath
|
|
}
|
|
|
|
func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchRequest) (workspacePath, mainRepoGitDir, worktreeID, worktreeBranch string) {
|
|
// Clone-based runtimes own their workspace filesystem. In particular, never
|
|
// pass a host checkout into Docker, SSH, or Sprites on reset/reconnect.
|
|
if backend, err := m.getExecutorBackend(req.ExecutorType); err == nil && backend.RequiresCloneURL() {
|
|
return "", "", "", ""
|
|
}
|
|
// Worktree mode requires a repository. Repo-less tasks fall through to the
|
|
// scratch workspace path below — even if the executor type was worktree.
|
|
useWorktree := req.UseWorktree && req.RepositoryPath != ""
|
|
if useWorktree && req.ACPSessionID == "" {
|
|
return "", "", "", ""
|
|
}
|
|
if useWorktree && req.ACPSessionID != "" {
|
|
return m.resolveResumeWorktreePath(ctx, req)
|
|
}
|
|
workspacePath = req.WorkspacePath
|
|
if req.RepositoryPath != "" && workspacePath == "" {
|
|
workspacePath = req.RepositoryPath
|
|
}
|
|
if workspacePath == "" && req.ACPSessionID != "" {
|
|
if resolved := m.resolveWorkspaceFromProvider(ctx, req); resolved != "" {
|
|
return resolved, "", "", ""
|
|
}
|
|
}
|
|
// For tasks without a repository, create a scratch workspace.
|
|
// - Non-ephemeral repo-less tasks: <homeDir>/tasks/<workspaceID>/<taskID>/
|
|
// (task-scoped, persists across sessions, mirrors the worktree task layout).
|
|
// - Ephemeral tasks (quick chat): <dataDir>/quick-chat/<sessionID>/
|
|
// (session-scoped, cleaned up on task delete via performTaskCleanup).
|
|
// Office tasks that have no repo (onboarding, planning) take the
|
|
// non-ephemeral branch and land under <homeDir>/tasks/...
|
|
if workspacePath == "" && (req.SessionID != "" || req.Owner.Kind == ExecutionOwnerRun) && m.dataDir != "" {
|
|
workspacePath = m.resolveScratchWorkspace(ctx, req)
|
|
}
|
|
return
|
|
}
|
|
|
|
func shouldPrepareEnvironment(req *LaunchRequest) bool {
|
|
if req == nil || req.ACPSessionID == "" {
|
|
return true
|
|
}
|
|
return req.UseWorktree && req.RepositoryPath != ""
|
|
}
|
|
|
|
// resolveScratchWorkspace creates and returns the scratch workspace path for a
|
|
// repo-less task. Returns empty string when the path could not be created.
|
|
func (m *Manager) resolveScratchWorkspace(ctx context.Context, req *LaunchRequest) string {
|
|
scratchPath := m.scratchWorkspacePath(req)
|
|
if scratchPath == "" {
|
|
return ""
|
|
}
|
|
if err := os.MkdirAll(scratchPath, 0755); err != nil {
|
|
m.logger.Warn("failed to create scratch workspace, continuing without workspace",
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("workspace_path", scratchPath),
|
|
zap.Error(err))
|
|
return ""
|
|
}
|
|
if !req.IsEphemeral && req.Owner.Kind != ExecutionOwnerRun {
|
|
if err := storageworkspaces.WriteOwnershipMarker(scratchPath, storageworkspaces.OwnershipMarker{
|
|
TaskID: req.TaskID, WorkspaceID: req.WorkspaceID, TaskDirName: req.TaskID,
|
|
LayoutVersion: storageworkspaces.LayoutVersionScratch,
|
|
}); err != nil {
|
|
m.logger.Warn("failed to mark scratch workspace ownership",
|
|
zap.String("workspace_path", scratchPath), zap.Error(err))
|
|
return ""
|
|
}
|
|
}
|
|
if err := m.initGitRepo(ctx, scratchPath); err != nil {
|
|
m.logger.Warn("failed to initialize git repository in scratch workspace",
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("workspace_path", scratchPath),
|
|
zap.Error(err))
|
|
// Continue anyway - git is optional for repo-less workspaces.
|
|
}
|
|
m.logger.Info("created scratch workspace",
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("workspace_path", scratchPath))
|
|
return scratchPath
|
|
}
|
|
|
|
// scratchWorkspacePath computes the scratch workspace path for a launch request.
|
|
// Returns empty string if the inputs are invalid (path traversal guard, missing IDs).
|
|
func (m *Manager) scratchWorkspacePath(req *LaunchRequest) string {
|
|
if req.Owner.Kind == ExecutionOwnerRun {
|
|
if req.WorkspaceID == "" || req.Owner.RunSessionID == "" || strings.ContainsAny(req.Owner.RunSessionID, `/\\`) {
|
|
m.logger.Warn("run-owned scratch workspace requires safe workspace and session IDs",
|
|
zap.String("run_session_id", req.Owner.RunSessionID),
|
|
zap.String("workspace_id", req.WorkspaceID))
|
|
return ""
|
|
}
|
|
return filepath.Join(m.dataDir, "office-runs", req.WorkspaceID, req.Owner.RunSessionID)
|
|
}
|
|
if req.IsEphemeral {
|
|
// Legacy quick-chat path — session-scoped, kept for backward compat with
|
|
// ephemeral one-shot flows.
|
|
if strings.ContainsAny(req.SessionID, `/\`) {
|
|
m.logger.Warn("session ID contains path separator, rejecting",
|
|
zap.String("session_id", req.SessionID))
|
|
return ""
|
|
}
|
|
return filepath.Join(m.dataDir, "quick-chat", req.SessionID)
|
|
}
|
|
// Non-ephemeral repo-less task: place under <homeDir>/tasks/<workspaceID>/<taskID>/
|
|
// so it sits alongside repo-bound worktrees and persists across sessions.
|
|
if req.TaskID == "" || req.WorkspaceID == "" {
|
|
m.logger.Warn("scratch workspace requires task_id and workspace_id",
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("workspace_id", req.WorkspaceID))
|
|
return ""
|
|
}
|
|
if invalidScratchPathID(req.TaskID) || invalidScratchPathID(req.WorkspaceID) {
|
|
m.logger.Warn("task or workspace ID contains path separator, rejecting",
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("workspace_id", req.WorkspaceID))
|
|
return ""
|
|
}
|
|
// m.dataDir is misnamed — cmd/kandev/agents.go passes cfg.ResolvedHomeDir()
|
|
// (the kandev root, e.g. ~/.kandev), not ResolvedDataDir(). So scratch
|
|
// workspaces live alongside the existing repo-bound worktree task dirs
|
|
// at <kandevHome>/tasks/<workspaceID>/<taskID>/.
|
|
return filepath.Join(m.dataDir, "tasks", req.WorkspaceID, req.TaskID)
|
|
}
|
|
|
|
func invalidScratchPathID(id string) bool {
|
|
return id == "." || id == ".." || strings.ContainsAny(id, `/\`)
|
|
}
|
|
|
|
// launchPrepareRequest copies the launch request, sets the resolved workspace path,
|
|
// and populates metadata from the request fields. Runtime/profile environment
|
|
// values are composed later, after every managed source has been collected.
|
|
func (m *Manager) launchPrepareRequest(req *LaunchRequest, profileInfo *AgentProfileInfo, workspacePath string) (LaunchRequest, string, error) {
|
|
if err := m.validateLaunchCheckoutOptions(req); err != nil {
|
|
return LaunchRequest{}, "", err
|
|
}
|
|
executionID := uuid.New().String()
|
|
reqWithWorktree := *req
|
|
reqWithWorktree.WorkspacePath = workspacePath
|
|
|
|
if reqWithWorktree.Metadata == nil {
|
|
reqWithWorktree.Metadata = make(map[string]interface{})
|
|
}
|
|
if req.TaskDescription != "" {
|
|
reqWithWorktree.Metadata["task_description"] = req.TaskDescription
|
|
}
|
|
if len(req.Attachments) > 0 {
|
|
reqWithWorktree.Metadata["attachments"] = req.Attachments
|
|
}
|
|
if req.SessionID != "" {
|
|
reqWithWorktree.Metadata["session_id"] = req.SessionID
|
|
}
|
|
if req.TurnID != "" {
|
|
reqWithWorktree.Metadata["prompt_turn_id"] = req.TurnID
|
|
}
|
|
|
|
if err := mergeRouteOverrideEnv(&reqWithWorktree); err != nil {
|
|
return LaunchRequest{}, "", err
|
|
}
|
|
return reqWithWorktree, executionID, nil
|
|
}
|
|
|
|
// mergeRouteOverrideEnv preserves legacy model-only routing overlays.
|
|
// Concrete execution profiles own their complete environment.
|
|
func mergeRouteOverrideEnv(req *LaunchRequest) error {
|
|
if req == nil || hasConcreteRouteExecutionProfile(req) || req.RouteOverride == nil || len(req.RouteOverride.Env) == 0 {
|
|
return nil
|
|
}
|
|
if req.Env == nil {
|
|
req.Env = make(map[string]string, len(req.RouteOverride.Env))
|
|
}
|
|
merged, err := gitconfigenv.Merge(req.Env, req.RouteOverride.Env)
|
|
if err != nil {
|
|
return fmt.Errorf("compose legacy route environment: %w", err)
|
|
}
|
|
req.Env = merged
|
|
return nil
|
|
}
|
|
|
|
// newProgressCallback builds a PrepareProgressCallback that publishes progress events for a task/session.
|
|
func (m *Manager) newProgressCallback(taskID, sessionID string) PrepareProgressCallback {
|
|
return func(step PrepareStep, stepIndex int, totalSteps int) {
|
|
m.eventPublisher.PublishPrepareProgress(sessionID, &PrepareProgressEventPayload{
|
|
TaskID: taskID,
|
|
SessionID: sessionID,
|
|
StepName: step.Name,
|
|
StepCommand: step.Command,
|
|
StepIndex: stepIndex,
|
|
TotalSteps: totalSteps,
|
|
Status: string(step.Status),
|
|
Output: step.Output,
|
|
Error: step.Error,
|
|
Warning: step.Warning,
|
|
WarningDetail: step.WarningDetail,
|
|
StartedAt: step.StartedAt,
|
|
EndedAt: step.EndedAt,
|
|
})
|
|
}
|
|
}
|
|
|
|
type prepareProgressRecorder struct {
|
|
mu sync.Mutex
|
|
steps []PrepareStep
|
|
callback PrepareProgressCallback
|
|
}
|
|
|
|
func newPrepareProgressRecorder(callback PrepareProgressCallback) *prepareProgressRecorder {
|
|
return &prepareProgressRecorder{callback: callback}
|
|
}
|
|
|
|
func (r *prepareProgressRecorder) Callback(offset int) PrepareProgressCallback {
|
|
return func(step PrepareStep, stepIndex int, totalSteps int) {
|
|
absoluteIndex := stepIndex + offset
|
|
r.recordStep(step, absoluteIndex)
|
|
if r.callback != nil {
|
|
r.callback(step, absoluteIndex, totalSteps+offset)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *prepareProgressRecorder) Merge(steps []PrepareStep) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for i, step := range steps {
|
|
if i >= len(r.steps) {
|
|
r.steps = append(r.steps, step)
|
|
continue
|
|
}
|
|
if r.steps[i].Name == "" {
|
|
r.steps[i] = step
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *prepareProgressRecorder) Len() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.steps)
|
|
}
|
|
|
|
func (r *prepareProgressRecorder) Steps() []PrepareStep {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]PrepareStep, len(r.steps))
|
|
copy(out, r.steps)
|
|
return out
|
|
}
|
|
|
|
func (r *prepareProgressRecorder) recordStep(step PrepareStep, index int) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for len(r.steps) <= index {
|
|
r.steps = append(r.steps, PrepareStep{})
|
|
}
|
|
r.steps[index] = step
|
|
}
|
|
|
|
// launchBuildExecutorRequest resolves MCP servers, builds the ExecutorCreateRequest,
|
|
// and creates the runtime instance.
|
|
func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID string, reqWithWorktree *LaunchRequest, agentConfig agents.Agent, profileInfo *AgentProfileInfo, mainRepoGitDir, worktreeID, worktreeBranch string, onProgress PrepareProgressCallback) (*ExecutorCreateRequest, *ExecutorInstance, ExecutorBackend, error) {
|
|
rt, err := m.getExecutorBackend(reqWithWorktree.ExecutorType)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("no runtime configured: %w", err)
|
|
}
|
|
|
|
env, err := m.buildEnvForExecution(ctx, executionID, reqWithWorktree, agentConfig, profileInfo)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("build launch environment: %w", err)
|
|
}
|
|
|
|
acpMcpServers, err := m.resolveMcpServersWithParams(ctx, executionProfileID(reqWithWorktree), reqWithWorktree.Metadata, agentConfig)
|
|
if err != nil {
|
|
m.logger.Warn("failed to resolve MCP servers for launch", zap.Error(err))
|
|
}
|
|
|
|
var mcpServers []McpServerConfig
|
|
for _, srv := range acpMcpServers {
|
|
mcpServers = append(mcpServers, McpServerConfig{
|
|
Name: srv.Name,
|
|
URL: srv.URL,
|
|
Type: srv.Type,
|
|
Command: srv.Command,
|
|
Args: srv.Args,
|
|
Env: srv.Env,
|
|
Headers: srv.Headers,
|
|
})
|
|
}
|
|
|
|
metadata := buildLaunchMetadata(reqWithWorktree, mainRepoGitDir, worktreeID, worktreeBranch)
|
|
if err := m.applyAuthoritativeKubernetesProfileConfig(ctx, reqWithWorktree, metadata); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
remoteContributions, err := collectRemoteContributions(reqWithWorktree)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if len(remoteContributions) > 0 {
|
|
metadata[MetadataKeyRemoteContributions] = remoteContributions
|
|
}
|
|
comparisonTargets, err := collectComparisonTargets(reqWithWorktree)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if len(comparisonTargets) > 0 {
|
|
metadata[MetadataKeyComparisonTargets] = comparisonTargets
|
|
}
|
|
contributionDestinations, err := collectContributionDestinations(reqWithWorktree)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if len(contributionDestinations) > 0 {
|
|
metadata[MetadataKeyContributionDestinations] = contributionDestinations
|
|
}
|
|
|
|
launchAuthToken, err := m.resolveLaunchAuthToken(ctx, reqWithWorktree, metadata)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("resolve launch auth token: %w", err)
|
|
}
|
|
|
|
var autoApproveOverride *bool
|
|
if profileInfo != nil {
|
|
autoApproveOverride = boolPtr(profileInfo.AutoApprove)
|
|
}
|
|
|
|
providerGatewayAuth, providerKeyEnvVar, providerKey, err := m.resolveProviderGatewayAuth(
|
|
ctx, profileInfo, agentConfig, models.ExecutorType(reqWithWorktree.ExecutorType).Runtime())
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if providerKey != "" && providerKeyEnvVar != "" {
|
|
if env == nil {
|
|
env = map[string]string{}
|
|
}
|
|
// The profile-declared provider key wins over any inherited value so a
|
|
// stale shell-exported OPENAI_API_KEY cannot shadow it.
|
|
env[providerKeyEnvVar] = providerKey
|
|
}
|
|
|
|
execReq := &ExecutorCreateRequest{
|
|
InstanceID: executionID,
|
|
ExecutorType: reqWithWorktree.ExecutorType,
|
|
TaskID: reqWithWorktree.TaskID,
|
|
TaskTitle: reqWithWorktree.TaskTitle,
|
|
SessionID: launchInventorySessionID(reqWithWorktree),
|
|
TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID,
|
|
WorkspaceReuseRequired: reqWithWorktree.WorkspaceReuseRequired,
|
|
AgentProfileID: executionProfileID(reqWithWorktree),
|
|
OfficeAgentProfileID: reqWithWorktree.AgentProfileID,
|
|
PromptTurnID: reqWithWorktree.TurnID,
|
|
WorkspacePath: reqWithWorktree.WorkspacePath,
|
|
WorkspaceSourceRoots: workspaceSourceRoots(reqWithWorktree.WorkspaceFolders, workspaceRepositorySpecsFromLaunch(reqWithWorktree)),
|
|
Protocol: string(agentConfig.Runtime().Protocol),
|
|
Env: env,
|
|
AutoApprovePermissions: profileInfo != nil && profileInfo.AutoApprove,
|
|
AutoApprovePermissionsOverride: autoApproveOverride,
|
|
Metadata: metadata,
|
|
AgentConfig: agentConfig,
|
|
ApprovedSecretEnvKeys: append([]string(nil), reqWithWorktree.ApprovedSecretEnvKeys...),
|
|
McpServers: mcpServers,
|
|
PreviousExecutionID: reqWithWorktree.PreviousExecutionID,
|
|
McpMode: reqWithWorktree.McpMode,
|
|
McpProviders: reqWithWorktree.McpProviders,
|
|
McpProfile: reqWithWorktree.McpProfile,
|
|
AuthToken: launchAuthToken,
|
|
BootstrapNonce: m.revealRuntimeSecret(ctx, metadata, MetadataKeyBootstrapNonceSecret),
|
|
AgentctlStartupConfig: agentctlStartupConfigForExecutor(m.agentctlStartupConfig, reqWithWorktree.ExecutorType),
|
|
OnProgress: onProgress,
|
|
RemoteContributions: remoteContributions,
|
|
ContributionDestinations: contributionDestinations,
|
|
ComparisonTargets: comparisonTargets,
|
|
ProviderGatewayAuth: providerGatewayAuth,
|
|
}
|
|
m.wireKubernetesInventoryPersistence(execReq, reqWithWorktree.ExecutorType)
|
|
|
|
launchCtx, launchCancel := withLaunchPhaseTimeout(ctx)
|
|
defer launchCancel()
|
|
if err := resumeRemoteInstancePreflight(launchCtx, rt, execReq); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
m.scheduleSSHLaunchWarning(launchCtx, reqWithWorktree, metadata, reqWithWorktree.SessionID)
|
|
|
|
execInstance, err := rt.CreateInstance(launchCtx, execReq)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to create execution: %w", err)
|
|
}
|
|
return execReq, execInstance, rt, nil
|
|
}
|
|
|
|
func isDockerExecutorType(executorType string) bool {
|
|
return executorType == string(models.ExecutorTypeLocalDocker) || executorType == string(models.ExecutorTypeRemoteDocker)
|
|
}
|
|
|
|
// resolveLaunchAuthToken returns the agentctl token a launch/resume hands the
|
|
// backend. Docker uses the environment-scoped container control token (#2843)
|
|
// so a sibling session can authenticate to a shared container, falling back
|
|
// to the session token only when workspace reuse is required and no
|
|
// container-control secret exists yet. Every other executor uses its own
|
|
// session handshake token directly, which SSH resume requires.
|
|
func (m *Manager) resolveLaunchAuthToken(ctx context.Context, req *LaunchRequest, metadata map[string]interface{}) (string, error) {
|
|
if isDockerExecutorType(req.ExecutorType) {
|
|
return m.revealContainerControlAuthToken(ctx, metadata, req.WorkspaceReuseRequired)
|
|
}
|
|
return m.revealRuntimeSecretValue(ctx, metadata, MetadataKeyAuthTokenSecret)
|
|
}
|
|
|
|
func resumeRemoteInstancePreflight(ctx context.Context, rt ExecutorBackend, req *ExecutorCreateRequest) error {
|
|
resumer, ok := rt.(RemoteSessionResumer)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := resumer.ResumeRemoteInstance(ctx, req); err != nil {
|
|
return fmt.Errorf("failed remote resume preflight: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runEnvironmentPreparer runs the environment preparer for the executor type, if one is registered.
|
|
// Returns the prepare result (nil if no preparer ran). Does NOT publish PrepareCompleted;
|
|
// the caller is responsible for publishing based on the returned result.
|
|
func (m *Manager) runEnvironmentPreparer(
|
|
ctx context.Context,
|
|
req *LaunchRequest,
|
|
workspacePath string,
|
|
) *EnvPrepareResult {
|
|
return m.runEnvironmentPreparerWithProgress(ctx, req, workspacePath, m.newProgressCallback(req.TaskID, req.SessionID))
|
|
}
|
|
|
|
func (m *Manager) runEnvironmentPreparerWithProgress(
|
|
ctx context.Context,
|
|
req *LaunchRequest,
|
|
workspacePath string,
|
|
onProgress PrepareProgressCallback,
|
|
) *EnvPrepareResult {
|
|
if m.preparerRegistry == nil {
|
|
return nil
|
|
}
|
|
// Preparer registry is keyed by ExecutorType (the "local"/"worktree"/
|
|
// "local_docker"/... taxonomy), not Runtime — so executor types that
|
|
// share a runtime backend (local + worktree both run on standalone)
|
|
// can still get distinct preparation logic.
|
|
execType := models.ExecutorType(req.ExecutorType)
|
|
preparer := m.preparerRegistry.Get(execType)
|
|
if preparer == nil && execType == "" {
|
|
// Fall back to LocalPreparer only for a genuinely empty ExecutorType
|
|
// — legacy task rows (e.g. PR-watcher-created tasks without an
|
|
// explicit executor) rely on local environment prep, including
|
|
// missing-branch detection. Typed-but-unregistered values like
|
|
// "remote_docker" intentionally return nil so the caller skips prep
|
|
// rather than running local git operations against a remote executor.
|
|
preparer = m.preparerRegistry.Get(models.ExecutorTypeLocal)
|
|
}
|
|
if preparer == nil {
|
|
return nil
|
|
}
|
|
// The EnvPrepareRequest carries the resolved Runtime (executor.Name),
|
|
// which preparer_script.go uses for runtime-level decisions like
|
|
// picking the default prepare template.
|
|
execName := execType.Runtime()
|
|
|
|
// Skip environment preparation for repo-less tasks (e.g. config chat).
|
|
// Preparers assume a repository is available; without one the session
|
|
// falls through to the quick-chat workspace path instead.
|
|
if req.RepositoryPath == "" {
|
|
m.logger.Debug("skipping environment preparer — no repository path",
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("preparer", preparer.Name()))
|
|
return nil
|
|
}
|
|
|
|
prepReq := buildEnvPrepareRequest(req, workspacePath, execName)
|
|
|
|
result, err := preparer.Prepare(ctx, prepReq, onProgress)
|
|
if err != nil {
|
|
m.logger.Warn("environment preparation failed",
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("preparer", preparer.Name()),
|
|
zap.Error(err))
|
|
return &EnvPrepareResult{
|
|
Success: false,
|
|
ErrorMessage: err.Error(),
|
|
Error: err,
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName executor.Name) *EnvPrepareRequest {
|
|
repoSetupScript, _ := req.Metadata[MetadataKeyRepoSetupScript].(string)
|
|
prepReq := &EnvPrepareRequest{
|
|
TaskID: req.TaskID,
|
|
WorkspaceID: req.WorkspaceID,
|
|
SessionID: req.SessionID,
|
|
TaskEnvironmentID: req.TaskEnvironmentID,
|
|
TaskTitle: req.TaskTitle,
|
|
ExecutorType: execName,
|
|
WorkspacePath: workspacePath,
|
|
RepositoryPath: req.RepositoryPath,
|
|
RepositoryID: req.RepositoryID,
|
|
TaskRepositoryID: req.TaskRepositoryID,
|
|
UseWorktree: req.UseWorktree,
|
|
WorkspaceReuseRequired: req.WorkspaceReuseRequired,
|
|
AllowBranchReplacement: req.AllowBranchReplacement,
|
|
WorktreeID: req.WorktreeID,
|
|
SetupScript: req.SetupScript,
|
|
RepoSetupScript: repoSetupScript,
|
|
BaseBranch: req.BaseBranch,
|
|
IntegrationRef: req.IntegrationRef,
|
|
DefaultBranch: req.DefaultBranch,
|
|
CheckoutBranch: req.CheckoutBranch,
|
|
PRNumber: req.PRNumber,
|
|
QualifiedPRBase: req.QualifiedPRBase,
|
|
RemoteContribution: req.RemoteContribution,
|
|
CheckoutOptions: req.CheckoutOptions,
|
|
ContributionDestination: req.ContributionDestination,
|
|
WorktreeBranch: getMetadataString(req.Metadata, MetadataKeyWorktreeBranch),
|
|
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
|
|
WorktreeBranchTemplate: req.WorktreeBranchTemplate,
|
|
WorktreeBranchTicket: req.WorktreeBranchTicket,
|
|
PullBeforeWorktree: req.PullBeforeWorktree,
|
|
RemoteSyncHandled: req.RemoteSyncHandled,
|
|
RefreshRepository: req.RefreshRepository,
|
|
RefreshRepositoryWithState: req.RefreshRepositoryWithState,
|
|
RemoteRefState: req.RemoteRefState,
|
|
TaskDirName: req.TaskDirName,
|
|
RepoName: req.RepoName,
|
|
BranchSlug: req.BranchSlug,
|
|
BranchIdentitySlug: req.BranchIdentitySlug,
|
|
Env: req.Env,
|
|
}
|
|
// Multi-repo: forward the repo list when the launch request carries one.
|
|
// Each per-repo entry inherits the request-level RepoSetupScript when its
|
|
// own is empty so single-repo callers continue to work unchanged.
|
|
if len(req.Repositories) > 0 {
|
|
specs := make([]RepoPrepareSpec, 0, len(req.Repositories))
|
|
for _, r := range req.Repositories {
|
|
setup := r.RepoSetupScript
|
|
if setup == "" {
|
|
setup = repoSetupScript
|
|
}
|
|
specs = append(specs, RepoPrepareSpec{
|
|
TaskRepositoryID: r.TaskRepositoryID,
|
|
RepositoryID: r.RepositoryID,
|
|
RepositoryPath: r.RepositoryPath,
|
|
RepoName: r.RepoName,
|
|
BaseBranch: r.BaseBranch,
|
|
IntegrationRef: r.IntegrationRef,
|
|
DefaultBranch: r.DefaultBranch,
|
|
CheckoutBranch: r.CheckoutBranch,
|
|
PRNumber: r.PRNumber,
|
|
QualifiedPRBase: r.QualifiedPRBase,
|
|
RemoteContribution: r.RemoteContribution,
|
|
CheckoutOptions: r.CheckoutOptions,
|
|
WorktreeID: r.WorktreeID,
|
|
AllowBranchReplacement: req.AllowBranchReplacement || r.AllowBranchReplacement,
|
|
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
|
|
WorktreeBranchTemplate: r.WorktreeBranchTemplate,
|
|
WorktreeBranchTicket: r.WorktreeBranchTicket,
|
|
PullBeforeWorktree: r.PullBeforeWorktree,
|
|
RemoteSyncHandled: r.RemoteSyncHandled,
|
|
RefreshRepository: r.RefreshRepository,
|
|
RefreshRepositoryWithState: r.RefreshRepositoryWithState,
|
|
RemoteRefState: r.RemoteRefState,
|
|
RepoSetupScript: setup,
|
|
BranchSlug: r.BranchSlug,
|
|
BranchIdentitySlug: r.BranchIdentitySlug,
|
|
ContributionDestination: r.ContributionDestination,
|
|
})
|
|
}
|
|
prepReq.Repositories = specs
|
|
}
|
|
return prepReq
|
|
}
|
|
|
|
// launchApplyPrepareResult applies workspace metadata from the preparer result.
|
|
// Returns an error if the preparer failed.
|
|
func (m *Manager) launchApplyPrepareResult(
|
|
req *LaunchRequest,
|
|
result *EnvPrepareResult,
|
|
workspacePath, mainRepoGitDir, worktreeID, worktreeBranch *string,
|
|
) error {
|
|
if !result.Success {
|
|
m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{
|
|
TaskID: req.TaskID,
|
|
SessionID: req.SessionID,
|
|
Success: false,
|
|
ErrorMessage: result.ErrorMessage,
|
|
Steps: result.Steps,
|
|
})
|
|
// Prefer the typed chain on result.Error so errors.Is/errors.As reach
|
|
// the underlying sentinel (worktree.ErrBranchCheckedOut, etc.). Fall
|
|
// back to the textual ErrorMessage when the preparer did not supply a
|
|
// typed error. The formatted message is identical in both cases.
|
|
if result.Error != nil {
|
|
displayMessage := result.ErrorMessage
|
|
if displayMessage == "" {
|
|
displayMessage = result.Error.Error()
|
|
}
|
|
return fmt.Errorf("environment preparation failed: %w", &prepareResultError{
|
|
message: displayMessage,
|
|
cause: result.Error,
|
|
})
|
|
}
|
|
return fmt.Errorf("environment preparation failed: %s", result.ErrorMessage)
|
|
}
|
|
if result.WorkspacePath != "" {
|
|
*workspacePath = result.WorkspacePath
|
|
}
|
|
if result.MainRepoGitDir != "" {
|
|
*mainRepoGitDir = result.MainRepoGitDir
|
|
}
|
|
if result.WorktreeID != "" {
|
|
*worktreeID = result.WorktreeID
|
|
}
|
|
if result.WorktreeBranch != "" {
|
|
*worktreeBranch = result.WorktreeBranch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) publishLaunchPrepareCompleted(req *LaunchRequest, result *EnvPrepareResult, recorder *prepareProgressRecorder, workspacePath string, success bool, err error) {
|
|
if req.ACPSessionID != "" && !shouldPrepareEnvironment(req) {
|
|
return
|
|
}
|
|
|
|
steps := recorder.Steps()
|
|
if len(steps) == 0 && result != nil {
|
|
steps = result.Steps
|
|
}
|
|
|
|
payload := &PrepareCompletedEventPayload{
|
|
TaskID: req.TaskID,
|
|
SessionID: req.SessionID,
|
|
Success: success,
|
|
WorkspacePath: workspacePath,
|
|
Steps: steps,
|
|
}
|
|
if result != nil {
|
|
payload.DurationMs = result.Duration.Milliseconds()
|
|
if payload.WorkspacePath == "" {
|
|
payload.WorkspacePath = result.WorkspacePath
|
|
}
|
|
}
|
|
if err != nil {
|
|
payload.Success = false
|
|
payload.ErrorMessage = err.Error()
|
|
}
|
|
m.eventPublisher.PublishPrepareCompleted(req.SessionID, payload)
|
|
}
|
|
|
|
// Launch launches a new agent for a task. Concurrent calls for the same
|
|
// req.SessionID are collapsed via the same singleflight bucket used by
|
|
// EnsureWorkspaceExecutionForSession and GetOrEnsureExecution — this closes
|
|
// the check-then-act race that previously could spawn a runtime instance,
|
|
// fail at executionStore.Add (race), and then have the orchestrator persist
|
|
// the orphan execution_id to disk before rollback completed (the original
|
|
// agent-execution-id divergence bug).
|
|
//
|
|
// If req.SessionID is empty (quick chat / pre-session contexts), no
|
|
// deduplication key exists and we fall through to direct execution.
|
|
func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) {
|
|
if req == nil {
|
|
return nil, errors.New("launch request is required")
|
|
}
|
|
if err := m.admitExecutionOwner(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
// AC-EXECUTORS-SURVIVAL-002.8/002.16: refuse rather than queue or block a
|
|
// launch for a session whose recovery guard is currently held. Checked
|
|
// first, before any activity lease or singleflight coalescing, so a
|
|
// guarded session can never partially acquire launch-path state.
|
|
if req.SessionID != "" {
|
|
if err := m.recoveryGuard.CheckLaunchAllowed(req.SessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if req.SessionID == "" {
|
|
activityLease, err := m.acquireActivity(ctx, activity.KindExecutionStarting)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
transferredActivity := false
|
|
defer func() {
|
|
if !transferredActivity {
|
|
activityLease.Release()
|
|
}
|
|
}()
|
|
activityLease.SetKind(activity.KindExecutionPreparing)
|
|
execution, launchErr := m.launchInternal(ctx, req)
|
|
if launchErr == nil && req.StartAgent {
|
|
m.markAgentStartPending(execution)
|
|
m.trackActivity(executionActivityKey(execution.ID), activityLease)
|
|
transferredActivity = true
|
|
}
|
|
return execution, launchErr
|
|
}
|
|
value, err := m.doCoalescedExecution(ctx, req.SessionID, func(sharedCtx context.Context) (interface{}, error) {
|
|
activityLease, acquireErr := m.acquireActivity(sharedCtx, activity.KindExecutionStarting)
|
|
if acquireErr != nil {
|
|
return nil, acquireErr
|
|
}
|
|
defer activityLease.Release()
|
|
activityLease.SetKind(activity.KindExecutionPreparing)
|
|
return m.launchInternal(sharedCtx, req)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
execution := value.(*AgentExecution)
|
|
// If this Launch call joined a workspace-only ensure peer's singleflight
|
|
// slot (EnsureWorkspaceExecutionForSession / GetOrEnsureExecution), the
|
|
// returned execution has no AgentCommand and the orchestrator's subsequent
|
|
// StartAgentProcess() would fail with "no agent command configured".
|
|
// Promote it in place so the agent subprocess can start against the
|
|
// existing agentctl instance.
|
|
if execution.AgentCommand == "" {
|
|
if err := m.promoteWorkspaceExecution(ctx, execution, req); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if req.StartAgent {
|
|
activityLease, err := m.acquireActivity(ctx, activity.KindExecutionPreparing)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.markAgentStartPending(execution)
|
|
m.trackActivity(executionActivityKey(execution.ID), activityLease)
|
|
}
|
|
return execution, nil
|
|
}
|
|
|
|
// markAgentStartPending distinguishes a launch that owns an imminent agent
|
|
// start from a workspace-only execution. It happens synchronously before
|
|
// Launch returns so a concurrent resume cannot mistake the registered runtime
|
|
// for stale state while StartAgentProcess is waiting for agentctl readiness.
|
|
func (m *Manager) markAgentStartPending(execution *AgentExecution) {
|
|
if execution == nil {
|
|
return
|
|
}
|
|
_ = m.executionStore.WithLock(execution.ID, func(current *AgentExecution) {
|
|
current.Status = v1.AgentStatusStarting
|
|
current.StartedAt = time.Now()
|
|
})
|
|
}
|
|
|
|
// promoteWorkspaceExecution populates the agent command fields on a
|
|
// workspace-only execution so a subsequent StartAgentProcess() can configure
|
|
// and start the agent subprocess. Concurrent promoters serialize through a
|
|
// dedicated singleflight key so they don't race on the shared AgentExecution
|
|
// pointer.
|
|
func (m *Manager) promoteWorkspaceExecution(ctx context.Context, execution *AgentExecution, req *LaunchRequest) error {
|
|
_, err := m.doCoalescedExecution(ctx, req.SessionID, func(sharedCtx context.Context) (interface{}, error) {
|
|
if err := m.ensureLaunchSessionStillActive(sharedCtx, req.SessionID, executionAdmissionAgent); err != nil {
|
|
return nil, err
|
|
}
|
|
activityLease, acquireErr := m.acquireActivity(sharedCtx, activity.KindExecutionPreparing)
|
|
if acquireErr != nil {
|
|
return nil, acquireErr
|
|
}
|
|
defer activityLease.Release()
|
|
if len(req.McpProviders) > 0 {
|
|
client, releaseClient := execution.AcquireAgentCtlClient()
|
|
defer releaseClient()
|
|
if client == nil {
|
|
return nil, fmt.Errorf("execution %q has no agentctl client for MCP provider promotion", execution.ID)
|
|
}
|
|
if err := client.SetMcpProviders(sharedCtx, req.McpProviders); err != nil {
|
|
return nil, fmt.Errorf("set MCP providers during workspace execution promotion: %w", err)
|
|
}
|
|
}
|
|
// Re-check after acquiring the slot — a peer Launch may have already
|
|
// promoted while we were waiting.
|
|
if execution.AgentCommand != "" {
|
|
return nil, nil
|
|
}
|
|
agentTypeName, profileInfo, err := m.resolveAgentProfile(sharedCtx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
agentConfig, ok := m.registry.Get(agentTypeName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent type %q not found in registry", agentTypeName)
|
|
}
|
|
if !agentConfig.Enabled() {
|
|
return nil, fmt.Errorf("agent type %q is disabled", agentTypeName)
|
|
}
|
|
preferNative := m.preferNativeBinary(agentConfig, execution.RuntimeName, execution.MetadataSnapshot())
|
|
cmds, err := m.buildAgentCommandWithContext(sharedCtx, req, profileInfo, agentConfig, preferNative)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := m.ensureLaunchSessionStillActive(sharedCtx, req.SessionID, executionAdmissionAgent); err != nil {
|
|
return nil, err
|
|
}
|
|
execution.AgentCommand = cmds.initial
|
|
execution.ContinueCommand = cmds.continue_
|
|
execution.AgentArgs = cmds.args
|
|
execution.ContinueArgs = cmds.continueArgs
|
|
if req.ACPSessionID != "" && execution.ACPSessionID == "" {
|
|
execution.ACPSessionID = req.ACPSessionID
|
|
}
|
|
if req.PreviousExecutionID != "" {
|
|
execution.isResumedSession = true
|
|
}
|
|
execution.IsPassthrough = req.IsPassthrough
|
|
if !req.IsPassthrough {
|
|
executorType := req.ExecutorType
|
|
if executorType == "" {
|
|
executorType = execution.ExecutorType
|
|
}
|
|
if err := m.materializeRuntimeProjectMCP(sharedCtx, execution, agentConfig, profileInfo, executorType); err != nil {
|
|
execution.AgentCommand = ""
|
|
execution.ContinueCommand = ""
|
|
execution.AgentArgs = nil
|
|
execution.ContinueArgs = nil
|
|
execution.isResumedSession = false
|
|
execution.IsPassthrough = false
|
|
return nil, err
|
|
}
|
|
}
|
|
// Workspace-only executions can be created from a session row that stores
|
|
// the task assignee. The launch request carries the acting Office identity,
|
|
// so persist it only after the final agent-launch admission succeeds.
|
|
if req.AgentProfileID != "" {
|
|
execution.OfficeAgentProfileID = req.AgentProfileID
|
|
m.persistExecutorRunning(context.WithoutCancel(sharedCtx), execution)
|
|
}
|
|
m.logger.Info("promoted workspace-only execution to agent execution",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("session_id", req.SessionID),
|
|
zap.String("agent_profile_id", req.AgentProfileID),
|
|
zap.Bool("resume", req.ACPSessionID != ""))
|
|
return nil, nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
// launchInternal is the body of Launch run inside the per-session singleflight
|
|
// slot. Callers must not invoke this directly except via Launch.
|
|
func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) {
|
|
m.logger.Debug("launching agent",
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("agent_profile_id", req.AgentProfileID),
|
|
zap.Bool("use_worktree", req.UseWorktree))
|
|
|
|
// 1. Resolve the agent profile to get agent type info
|
|
agentTypeName, profileInfo, err := m.resolveAgentProfile(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 2. Get agent config from registry
|
|
agentConfig, ok := m.registry.Get(agentTypeName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent type %q not found in registry", agentTypeName)
|
|
}
|
|
if !agentConfig.Enabled() {
|
|
return nil, fmt.Errorf("agent type %q is disabled", agentTypeName)
|
|
}
|
|
if err := m.prepareManagedGoCacheEnvironment(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 3. Check if session already has an agent running. A workspace-only
|
|
// execution created by EnsureWorkspaceExecutionForSession /
|
|
// GetOrEnsureExecution has no AgentCommand — return it so the outer Launch
|
|
// can promote it instead of erroring as if a real agent were running.
|
|
if req.SessionID != "" {
|
|
if existingExecution, exists := m.executionStore.GetBySessionID(req.SessionID); exists {
|
|
if existingExecution.AgentCommand == "" {
|
|
return existingExecution, nil
|
|
}
|
|
return nil, fmt.Errorf("%w: session %q (execution: %s)", ErrAgentAlreadyRunning, req.SessionID, existingExecution.ID)
|
|
}
|
|
}
|
|
|
|
// 4. Resolve workspace path (non-worktree executors use this directly)
|
|
workspacePath, mainRepoGitDir, worktreeID, worktreeBranch := m.launchResolveWorkspacePath(ctx, req)
|
|
if err := validateLaunchWorkspaceAdmission(ctx, req, workspacePath); err != nil {
|
|
return nil, err
|
|
}
|
|
owner := ownedDirectoryLinkOwner(req.TaskID, req.TaskDirName)
|
|
if err := reconcileWorkspaceSources(ctx, workspacePath, req.WorkspaceFolders, owner); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.ExecutorType == string(models.ExecutorTypeLocal) || req.ExecutorType == legacyExecutorTypeLocalPC {
|
|
if err := reconcileWorkspaceRepositories(workspacePath, workspaceRepositorySpecsFromLaunch(req), m.logger, owner); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
progressRecorder := newPrepareProgressRecorder(m.newProgressCallback(req.TaskID, req.SessionID))
|
|
|
|
// Compose the request before preparation so setup scripts receive the same
|
|
// final snapshot that the runtime, agent, shell, and terminal will use.
|
|
reqWithWorktree, executionID, err := m.launchPrepareRequest(req, profileInfo, workspacePath)
|
|
if err != nil {
|
|
m.publishLaunchPrepareCompleted(req, nil, progressRecorder, workspacePath, false, err)
|
|
return nil, err
|
|
}
|
|
finalEnv, err := m.buildEnvForExecution(ctx, executionID, &reqWithWorktree, agentConfig, profileInfo)
|
|
if err != nil {
|
|
m.publishLaunchPrepareCompleted(req, nil, progressRecorder, workspacePath, false, err)
|
|
return nil, err
|
|
}
|
|
reqWithWorktree.Env = finalEnv
|
|
reqWithWorktree.EnvironmentDefinitions = nil
|
|
reqWithWorktree.EnvironmentResolutionRequired = false
|
|
reqWithWorktree.EnvironmentFinalized = true
|
|
|
|
// 4b. Run environment preparation (if preparer registered for this executor type).
|
|
// Native ACP resume normally reuses the already-prepared workspace, but a
|
|
// worktree resume must re-enter preparation so a missing branch can be
|
|
// reported and an explicit replacement action can materialize it.
|
|
var prepResult *EnvPrepareResult
|
|
if shouldPrepareEnvironment(req) {
|
|
prepResult = m.runEnvironmentPreparerWithProgress(ctx, &reqWithWorktree, workspacePath, progressRecorder.Callback(0))
|
|
} else {
|
|
m.logger.Debug("skipping environment preparation for resumed session",
|
|
zap.String("task_id", req.TaskID),
|
|
zap.String("session_id", req.SessionID))
|
|
}
|
|
if prepResult != nil {
|
|
progressRecorder.Merge(prepResult.Steps)
|
|
if err := m.launchApplyPrepareResult(&reqWithWorktree, prepResult, &workspacePath, &mainRepoGitDir, &worktreeID, &worktreeBranch); err != nil {
|
|
return nil, err
|
|
}
|
|
// The preparer owns the final workspace location for worktree-backed
|
|
// launches. Keep the executor request in sync with the local launch
|
|
// state; otherwise standalone receives the repository path (or an empty
|
|
// path) that was present before preparation completed.
|
|
reqWithWorktree.WorkspacePath = workspacePath
|
|
}
|
|
|
|
// 6b. Deploy per-profile skills + custom prompt (ADR 0005 Wave A).
|
|
// Best-effort: a deploy failure is logged but does not abort the launch
|
|
// — the agent can still start with whatever skills were already on disk.
|
|
m.runSkillDeploy(ctx, req, &reqWithWorktree)
|
|
|
|
// 7. Build runtime request and create instance (agent not started yet)
|
|
var runtimeProgress PrepareProgressCallback
|
|
if shouldPrepareEnvironment(req) {
|
|
runtimeProgress = progressRecorder.Callback(progressRecorder.Len())
|
|
}
|
|
execReq, execInstance, rt, err := m.launchBuildExecutorRequest(ctx, executionID, &reqWithWorktree, agentConfig, profileInfo, mainRepoGitDir, worktreeID, worktreeBranch, runtimeProgress)
|
|
if err != nil {
|
|
m.publishLaunchPrepareCompleted(req, prepResult, progressRecorder, workspacePath, false, err)
|
|
return nil, err
|
|
}
|
|
// A reset/relaunch receives the complete durable repository projection from
|
|
// the orchestrator. Reconcile it through the fresh live agentctl rather than
|
|
// relying on the legacy primary-repository prepare script alone.
|
|
if rt.RequiresCloneURL() && len(reqWithWorktree.RepoSpecs()) > 1 && execInstance != nil && execInstance.Client != nil {
|
|
projection, projectionErr := remoteWorkspaceProjectionFromLaunch(&reqWithWorktree)
|
|
if projectionErr == nil {
|
|
projectionErr = materializeWorkspaceRepositories(ctx, execInstance.Client, projection)
|
|
}
|
|
if projectionErr != nil {
|
|
rollbackErr := stopRuntimeInstanceAndRelease(context.WithoutCancel(ctx), rt, execInstance, true)
|
|
err = errors.Join(
|
|
fmt.Errorf("reconstruct remote workspace repositories: %w", projectionErr),
|
|
rollbackErr,
|
|
)
|
|
m.publishLaunchPrepareCompleted(req, prepResult, progressRecorder, workspacePath, false, err)
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Remote executors (Docker, Sprites) clone the workspace inside the
|
|
// container, so the worktree path's host-side copy_files never ran.
|
|
// Ship the bytes through agentctl now that the instance is up. The
|
|
// worktree path is already gated by reqWithWorktree.UseWorktree, so
|
|
// it's safe to skip when that's true. For multi-repo launches, loop
|
|
// over every per-repo spec — each repo's CopyFiles ships into its
|
|
// own RepoName subdir under the workspace.
|
|
if !reqWithWorktree.UseWorktree && execInstance != nil && execInstance.Client != nil {
|
|
shipRemoteCopyfilesForLaunch(ctx, m.logger, &reqWithWorktree, execInstance.Client, runtimeProgress, progressRecorder)
|
|
}
|
|
|
|
if prepResult != nil {
|
|
prepResult.Steps = progressRecorder.Steps()
|
|
}
|
|
m.publishLaunchPrepareCompleted(req, prepResult, progressRecorder, workspacePath, true, nil)
|
|
|
|
// Build the in-memory AgentExecution from the runtime instance. Extracted
|
|
// to keep launchInternal under the cyclomatic-complexity budget.
|
|
execution, err := m.buildExecutionFromInstance(ctx, req, execReq, execInstance, rt, profileInfo, agentConfig, prepResult)
|
|
if err != nil {
|
|
// Command resolution failed (e.g. a configured command_prefix could not
|
|
// be tokenised). The execution isn't built yet, so stop the runtime
|
|
// instance directly to avoid leaking it, then fail closed.
|
|
if rt != nil && execInstance != nil {
|
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
if stopErr := stopRuntimeInstanceAndRelease(cleanupCtx, rt, execInstance, true); stopErr != nil {
|
|
m.logger.Warn("failed to stop runtime instance after command resolution error",
|
|
zap.Error(stopErr))
|
|
}
|
|
cancel()
|
|
}
|
|
if execInstance != nil && execInstance.Client != nil {
|
|
execInstance.Client.Close()
|
|
}
|
|
return nil, err
|
|
}
|
|
if !reqWithWorktree.IsPassthrough {
|
|
if err := m.materializeRuntimeProjectMCP(ctx, execution, agentConfig, profileInfo, reqWithWorktree.ExecutorType); err != nil {
|
|
m.rollbackLaunchExecution(ctx, rt, execInstance, execution, "project MCP materialization failed")
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Track + persist + publish. Returns the rollback error if Add lost a race.
|
|
if err := m.registerAndPublishExecution(ctx, execution, rt, execInstance, req.SessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.logger.Debug("agentctl execution created (agent not started)",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("task_id", req.TaskID),
|
|
zap.Stringer("runtime", execution.RuntimeName))
|
|
|
|
return execution, nil
|
|
}
|
|
|
|
func (m *Manager) admitExecutionOwner(ctx context.Context, req *LaunchRequest) error {
|
|
if req == nil || req.Owner.Kind != ExecutionOwnerRun {
|
|
return nil
|
|
}
|
|
admission := req.OwnerAdmission
|
|
if admission == nil {
|
|
admission = m.ownerAdmission
|
|
}
|
|
if admission == nil {
|
|
return fmt.Errorf("execution owner %q has no admission provider", req.Owner.Kind)
|
|
}
|
|
return admission.AdmitExecution(ctx, req.Owner)
|
|
}
|
|
|
|
func executionOwnerKind(execution *AgentExecution) ExecutionOwnerKind {
|
|
if execution == nil {
|
|
return ""
|
|
}
|
|
if execution.RunID != "" || execution.RunSessionID != "" {
|
|
return ExecutionOwnerRun
|
|
}
|
|
if execution.TaskID != "" || execution.SessionID != "" {
|
|
return ExecutionOwnerTask
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// validateLaunchWorkspaceAdmission checks host-side repository identity before
|
|
// any durable folder or repository links are reconciled. A linked worktree is
|
|
// valid when its Git common directory matches the selected repository. Remote
|
|
// executors own a different filesystem and perform their checks in the
|
|
// executor backend instead.
|
|
func validateLaunchWorkspaceAdmission(ctx context.Context, req *LaunchRequest, workspacePath string) error {
|
|
if req == nil || workspacePath == "" || models.IsRemoteExecutorType(models.ExecutorType(req.ExecutorType)) {
|
|
return nil
|
|
}
|
|
if req.ExecutorType != string(models.ExecutorTypeLocal) &&
|
|
req.ExecutorType != legacyExecutorTypeLocalPC &&
|
|
(req.ExecutorType != string(models.ExecutorTypeWorktree) || req.ACPSessionID == "") {
|
|
return nil
|
|
}
|
|
repositories := workspaceRepositorySpecsFromLaunch(req)
|
|
if len(repositories) == 0 {
|
|
return nil
|
|
}
|
|
for index, repository := range repositories {
|
|
candidate := workspacePath
|
|
entry := workspaceRepositoryEntryName(repository.RepoName)
|
|
if index > 0 {
|
|
candidate = filepath.Join(workspacePath, entry)
|
|
} else if len(repositories) > 1 && validateLocalRepositoryWorkspace(ctx, candidate, repository.RepositoryPath) != nil {
|
|
candidate = filepath.Join(workspacePath, entry)
|
|
}
|
|
// A missing worktree during ACP resume must reach WorktreePreparer.
|
|
// It classifies a deleted branch and returns the typed recovery error
|
|
// used by the explicit replacement action. The preparer still validates
|
|
// the saved worktree and task environment identity before any reuse.
|
|
if shouldDeferMissingWorktreeResumeValidation(req, candidate) {
|
|
continue
|
|
}
|
|
if err := validateLocalRepositoryWorkspace(ctx, candidate, repository.RepositoryPath); err != nil {
|
|
return fmt.Errorf("validate launch workspace repository %q: %w", repository.RepositoryID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// workspaceRepositoryEntryName returns the directory segment below the task
|
|
// root that holds a repository's checkout. A launch spec carries the
|
|
// repository's display name, which may contain path separators or a drive
|
|
// letter; the worktree manager sanitizes it before creating the directory, so
|
|
// admission has to resolve the same segment or it inspects a path that was
|
|
// never written. An unusable name is left as-is for the caller to reject.
|
|
func workspaceRepositoryEntryName(repoName string) string {
|
|
if sanitized := worktree.SanitizeRepoDirName(repoName); sanitized != "" {
|
|
return sanitized
|
|
}
|
|
return repoName
|
|
}
|
|
|
|
func shouldDeferMissingWorktreeResumeValidation(req *LaunchRequest, workspacePath string) bool {
|
|
if req == nil || req.ExecutorType != string(models.ExecutorTypeWorktree) || req.ACPSessionID == "" || workspacePath == "" {
|
|
return false
|
|
}
|
|
_, err := os.Stat(workspacePath)
|
|
return errors.Is(err, os.ErrNotExist)
|
|
}
|
|
|
|
// buildExecutionFromInstance turns the spawned ExecutorInstance + request shape
|
|
// into an in-memory *AgentExecution ready for Add. Pulled out of launchInternal
|
|
// to keep the orchestration loop's cyclomatic complexity within the linter budget.
|
|
func (m *Manager) buildExecutionFromInstance(
|
|
ctx context.Context,
|
|
req *LaunchRequest,
|
|
execReq *ExecutorCreateRequest,
|
|
execInstance *ExecutorInstance,
|
|
rt ExecutorBackend,
|
|
profileInfo *AgentProfileInfo,
|
|
agentConfig agents.Agent,
|
|
prepResult *EnvPrepareResult,
|
|
) (*AgentExecution, error) {
|
|
execution := execInstance.ToAgentExecution(execReq)
|
|
execution.SessionID = req.SessionID
|
|
execution.ResumeAttemptID = ResumeAttemptIDFromContext(ctx)
|
|
execution.RuntimeName = rt.Name()
|
|
execution.WorkspaceID = req.WorkspaceID
|
|
execution.RunID = req.Owner.RunID
|
|
execution.RunSessionID = req.Owner.RunSessionID
|
|
execution.RunAttempt = req.Owner.Attempt
|
|
execution.Owner = req.Owner
|
|
execution.OwnerAdmission = req.OwnerAdmission
|
|
if req.Owner.AgentProfileID != "" {
|
|
execution.OfficeAgentProfileID = req.Owner.AgentProfileID
|
|
}
|
|
if req.ACPSessionID != "" {
|
|
execution.ACPSessionID = req.ACPSessionID
|
|
}
|
|
execution.PrepareResult = prepResult
|
|
if req.PreviousExecutionID != "" {
|
|
execution.isResumedSession = true
|
|
}
|
|
execution.IsPassthrough = req.IsPassthrough
|
|
// Use the resolved runtime (set from rt.Name() above), matching
|
|
// promoteWorkspaceExecution's call site rather than re-deriving from the
|
|
// requested ExecutorType.
|
|
preferNative := m.preferNativeBinary(agentConfig, execution.RuntimeName, execReq.Metadata)
|
|
cmds, err := m.buildAgentCommandWithContext(ctx, req, profileInfo, agentConfig, preferNative)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
execution.AgentCommand = cmds.initial
|
|
execution.ContinueCommand = cmds.continue_
|
|
execution.AgentArgs = cmds.args
|
|
execution.ContinueArgs = cmds.continueArgs
|
|
return execution, nil
|
|
}
|
|
|
|
// registerAndPublishExecution does the post-spawn lockstep dance: track in the
|
|
// in-memory store, persist the executors_running row, publish events, kick off
|
|
// the readiness poll. On a session-conflict race during Add, rolls back the
|
|
// runtime instance so we never leak a subprocess.
|
|
func (m *Manager) registerAndPublishExecution(
|
|
ctx context.Context,
|
|
execution *AgentExecution,
|
|
rt ExecutorBackend,
|
|
execInstance *ExecutorInstance,
|
|
sessionID string,
|
|
) error {
|
|
if execution != nil {
|
|
ownerReq := &LaunchRequest{
|
|
OwnerAdmission: execution.OwnerAdmission,
|
|
Owner: ExecutionOwner{
|
|
Kind: executionOwnerKind(execution),
|
|
WorkspaceID: execution.WorkspaceID,
|
|
TaskID: execution.TaskID,
|
|
SessionID: execution.SessionID,
|
|
RunID: execution.RunID,
|
|
RunSessionID: execution.RunSessionID,
|
|
Attempt: execution.RunAttempt,
|
|
AgentProfileID: execution.OfficeAgentProfileID,
|
|
},
|
|
}
|
|
if ownerReq.Owner.Kind == ExecutionOwnerRun {
|
|
if err := m.admitExecutionOwner(ctx, ownerReq); err != nil {
|
|
m.rollbackLaunchExecution(ctx, rt, execInstance, execution, "owner admission failed")
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil {
|
|
m.rollbackLaunchExecution(ctx, rt, execInstance, execution, "session ended during runtime creation")
|
|
return err
|
|
}
|
|
if addErr := m.executionStore.Add(execution); addErr != nil {
|
|
if errors.Is(addErr, ErrExecutionAlreadyExistsForSession) {
|
|
m.rollbackRacedExecution(ctx, rt, execInstance, execution)
|
|
return fmt.Errorf("%w: session %q (race resolved during register)", ErrAgentAlreadyRunning, sessionID)
|
|
}
|
|
return fmt.Errorf("failed to register execution: %w", addErr)
|
|
}
|
|
// This execution is durably in the store as of the Add above, and it got
|
|
// there via Launch -- never via the recovery path, which adds directly to
|
|
// executionStore and marks retrackedSessions instead -- so sessionID's row
|
|
// is "created this lifetime" from this point on (see
|
|
// standaloneOwnSessions).
|
|
m.markSessionCreatedThisLifetime(sessionID)
|
|
isKubernetes := execution.RuntimeName == agentruntime.RuntimeKubernetes
|
|
var createdRuntimeSecrets map[string]bool
|
|
if isKubernetes {
|
|
var err error
|
|
createdRuntimeSecrets, err = m.persistRequiredKubernetesRuntimeSecrets(ctx, execInstance, execution)
|
|
if err != nil {
|
|
m.rollbackRegisteredLaunch(rt, execInstance, execution, "Kubernetes runtime secret persistence failed")
|
|
return err
|
|
}
|
|
}
|
|
// Make the execution visible to durable cleanup before the final session
|
|
// read. This closes the precheck -> Add -> persist gap: deletion cleanup can
|
|
// now inventory the row, while a deletion that already ran is caught below.
|
|
if err := m.persistExecutorRunningResult(ctx, execution); err != nil {
|
|
secretCleanupErr := m.deleteCreatedRuntimeSecrets(ctx, execution, createdRuntimeSecrets)
|
|
m.rollbackRegisteredLaunchAfterPersistFailure(rt, execInstance, execution)
|
|
return errors.Join(fmt.Errorf("persist execution registration: %w", err), secretCleanupErr)
|
|
}
|
|
|
|
if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil {
|
|
if errors.Is(err, errTaskCleanupActive) {
|
|
m.rollbackRegisteredLaunchForTaskCleanup(rt, execInstance, execution)
|
|
} else {
|
|
m.rollbackRegisteredLaunch(rt, execInstance, execution, "session ended during execution registration")
|
|
}
|
|
return err
|
|
}
|
|
m.setRuntimeInterest(execution.SessionID, true)
|
|
|
|
if !isKubernetes {
|
|
if err := m.persistRuntimeSecrets(ctx, execInstance, execution); err != nil {
|
|
m.rollbackRegisteredLaunch(rt, execInstance, execution, "runtime secret persistence failed")
|
|
return err
|
|
}
|
|
}
|
|
|
|
go m.pollOneRemoteStatus(context.Background(), execution)
|
|
|
|
m.eventPublisher.PublishAgentEvent(ctx, events.AgentStarted, execution)
|
|
m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlStarting, execution, "")
|
|
|
|
// Wait for agentctl to be ready (for shell/workspace access).
|
|
// NOTE: This does NOT start the agent process — call StartAgentProcess() explicitly.
|
|
go m.waitForAgentctlReady(execution)
|
|
return nil
|
|
}
|
|
|
|
// ensureLaunchSessionStillActive closes the remote-runtime creation race: SSH,
|
|
// Docker, and other remote CreateInstance calls can outlive a concurrent task
|
|
// delete. Callers read both immediately before and after registration. The
|
|
// durable cleanup-intent check is the admission boundary between them: either
|
|
// launch persists first and cleanup's final inventory observes it, or cleanup
|
|
// persists first and launch rolls the runtime back.
|
|
func (m *Manager) ensureLaunchSessionStillActive(
|
|
ctx context.Context,
|
|
sessionID string,
|
|
purpose executionAdmissionPurpose,
|
|
) error {
|
|
if m.executorProfileReader == nil || sessionID == "" {
|
|
return nil
|
|
}
|
|
session, err := m.executorProfileReader.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("verify session before registering execution: %w", err)
|
|
}
|
|
if session == nil {
|
|
return fmt.Errorf("verify session before registering execution: session %q not found", sessionID)
|
|
}
|
|
if purpose == executionAdmissionWorkspaceOnly {
|
|
if err := m.ensureWorkspaceTaskAdmission(ctx, session); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
cleanupActive, err := m.executorProfileReader.HasActiveTaskResourceCleanupJob(ctx, session.TaskID)
|
|
if err != nil {
|
|
return fmt.Errorf("verify task cleanup before registering execution: %w", err)
|
|
}
|
|
if cleanupActive {
|
|
return fmt.Errorf("verify task cleanup before registering execution: %w for task %q", errTaskCleanupActive, session.TaskID)
|
|
}
|
|
// Re-read after the cleanup-intent lookup. A cleanup can transition to a
|
|
// terminal state between separate queries; its job then no longer appears
|
|
// active, but the session deletion/cancellation remains authoritative.
|
|
session, err = m.executorProfileReader.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("reverify session after task cleanup admission: %w", err)
|
|
}
|
|
if session == nil {
|
|
return fmt.Errorf("reverify session after task cleanup admission: session %q not found", sessionID)
|
|
}
|
|
if purpose == executionAdmissionWorkspaceOnly {
|
|
if err := m.ensureWorkspaceTaskAdmission(ctx, session); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return validateLaunchSessionState(sessionID, session, purpose)
|
|
}
|
|
|
|
func validateLaunchSessionState(
|
|
sessionID string,
|
|
session *models.TaskSession,
|
|
purpose executionAdmissionPurpose,
|
|
) error {
|
|
switch session.State {
|
|
case models.TaskSessionStateCancelled,
|
|
models.TaskSessionStateCompleted,
|
|
models.TaskSessionStateFailed:
|
|
if purpose == executionAdmissionWorkspaceOnly {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("verify session before registering execution: session %q is %s: %w", sessionID, session.State, ErrSessionTerminal)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (m *Manager) ensureWorkspaceTaskAdmission(ctx context.Context, session *models.TaskSession) error {
|
|
if session == nil || session.TaskID == "" {
|
|
return fmt.Errorf("workspace restore has ambiguous task ownership")
|
|
}
|
|
task, err := m.executorProfileReader.GetTask(ctx, session.TaskID)
|
|
if err != nil {
|
|
return fmt.Errorf("verify task before registering workspace execution: %w", err)
|
|
}
|
|
if task == nil || task.ID == "" || task.ID != session.TaskID {
|
|
return fmt.Errorf("verify task before registering workspace execution: task %q not found", session.TaskID)
|
|
}
|
|
if task.ArchivedAt != nil {
|
|
return fmt.Errorf("verify task before registering workspace execution: task %q is archived: %w", session.TaskID, ErrSessionTerminal)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rollbackRegisteredLaunchForTaskCleanup cannot assume the session is
|
|
// terminal: the task mutation may still fail after persisting its prepared
|
|
// cleanup intent. Preserve resumable state while removing the rejected live
|
|
// execution; committed cleanup can subsequently remove the stopped row.
|
|
func (m *Manager) rollbackRegisteredLaunchForTaskCleanup(
|
|
rt ExecutorBackend,
|
|
execInstance *ExecutorInstance,
|
|
execution *AgentExecution,
|
|
) {
|
|
m.rollbackRegisteredLaunchWithRetry(rt, execInstance, execution, true, "task cleanup won execution registration")
|
|
}
|
|
|
|
// rollbackRegisteredLaunchAfterPersistFailure preserves any prior durable row
|
|
// for the session. If the failed upsert actually committed before returning an
|
|
// ambiguous transport error, clean it up resume-safely only after confirming
|
|
// that it belongs to this exact execution.
|
|
func (m *Manager) rollbackRegisteredLaunchAfterPersistFailure(
|
|
rt ExecutorBackend,
|
|
execInstance *ExecutorInstance,
|
|
execution *AgentExecution,
|
|
) {
|
|
m.rollbackRegisteredLaunchWithRetry(rt, execInstance, execution, false, "execution registration persistence failed")
|
|
}
|
|
|
|
func (m *Manager) rollbackRegisteredLaunchWithRetry(
|
|
rt ExecutorBackend,
|
|
execInstance *ExecutorInstance,
|
|
execution *AgentExecution,
|
|
taskCleanupActive bool,
|
|
reason string,
|
|
) {
|
|
m.rollbackRegisteredLaunchWithRetryMode(rt, execInstance, execution, taskCleanupActive, false, reason)
|
|
}
|
|
|
|
func (m *Manager) rollbackRegisteredLaunchWithRetryMode(
|
|
rt ExecutorBackend,
|
|
execInstance *ExecutorInstance,
|
|
execution *AgentExecution,
|
|
taskCleanupActive bool,
|
|
discardDurable bool,
|
|
reason string,
|
|
) {
|
|
if err := m.stopRegisteredLaunchRuntime(rt, execInstance, execution); err == nil {
|
|
m.finishRegisteredLaunchRollback(execution, taskCleanupActive, discardDurable)
|
|
return
|
|
} else {
|
|
m.logger.Warn("registered launch rollback retained ownership after stop failure",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.String("reason", reason),
|
|
zap.Error(err))
|
|
}
|
|
|
|
m.wg.Add(1)
|
|
go func() {
|
|
defer m.wg.Done()
|
|
for attempt := 0; attempt < registeredLaunchRollbackRetries; attempt++ {
|
|
timer := time.NewTimer(registeredLaunchRollbackRetryDelays[attempt])
|
|
select {
|
|
case <-m.stopCh:
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
if _, exists := m.executionStore.Get(execution.ID); !exists {
|
|
return
|
|
}
|
|
if err := m.stopRegisteredLaunchRuntime(rt, execInstance, execution); err == nil {
|
|
m.finishRegisteredLaunchRollback(execution, taskCleanupActive, discardDurable)
|
|
return
|
|
} else {
|
|
m.logger.Warn("registered launch rollback retry failed",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.Int("attempt", attempt+1),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
m.logger.Error("registered launch rollback exhausted retries; retaining cleanup ownership",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.String("reason", reason))
|
|
}()
|
|
}
|
|
|
|
func (m *Manager) stopRegisteredLaunchRuntime(
|
|
rt ExecutorBackend,
|
|
execInstance *ExecutorInstance,
|
|
execution *AgentExecution,
|
|
) error {
|
|
execution.remoteInstanceLifecycleMu.Lock()
|
|
defer execution.remoteInstanceLifecycleMu.Unlock()
|
|
execution.agentctlLifecycleMu.Lock()
|
|
defer execution.agentctlLifecycleMu.Unlock()
|
|
if rt != nil && execInstance != nil {
|
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
retainedKubernetesResume := execution.RuntimeName == agentruntime.RuntimeKubernetes && execution.isResumedSession
|
|
err := rt.StopInstance(cleanupCtx, execInstance, !retainedKubernetesResume)
|
|
if err == nil && execution.RuntimeName == agentruntime.RuntimeKubernetes && !retainedKubernetesResume {
|
|
err = m.deleteKubernetesRuntimeSecrets(cleanupCtx, execution.MetadataSnapshot())
|
|
}
|
|
if err == nil && !retainedKubernetesResume {
|
|
err = releaseExecutorInstanceRuntimeInventory(cleanupCtx, execInstance)
|
|
if err != nil {
|
|
err = fmt.Errorf("release stopped runtime inventory: %w", err)
|
|
}
|
|
}
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if client := execution.currentAgentCtlClient(); client != nil { // protected by agentctlLifecycleMu
|
|
client.Close()
|
|
}
|
|
execution.EndSessionSpan()
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) finishRegisteredLaunchRollback(execution *AgentExecution, taskCleanupActive, discardDurable bool) {
|
|
if execution.RuntimeName == agentruntime.RuntimeKubernetes && execution.isResumedSession {
|
|
// A failed resume only owns the newly opened local client and forward. The
|
|
// recorded Pod/PVC inventory remains the authority for a later retry or
|
|
// terminal cleanup, so never discard its durable row here.
|
|
m.executionStore.Remove(execution.ID)
|
|
return
|
|
}
|
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if !discardDurable || taskCleanupActive {
|
|
m.deleteExecutorRunning(cleanupCtx, executionInventorySessionID(execution))
|
|
} else if reader, ok := m.runningWriter.(executorRunningReader); ok {
|
|
running, err := reader.GetExecutorRunningBySessionID(cleanupCtx, execution.SessionID)
|
|
if err == nil && running != nil && running.AgentExecutionID == execution.ID {
|
|
if err := m.deleteExecutorRunningRow(cleanupCtx, execution.SessionID, execution.ID); err != nil &&
|
|
!errors.Is(err, models.ErrExecutorRunningNotFound) {
|
|
m.logger.Warn("failed to delete exact executor-running row after launch rollback",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
m.executionStore.Remove(execution.ID)
|
|
}
|
|
|
|
func (m *Manager) rollbackLaunchExecution(_ context.Context, rt ExecutorBackend, execInstance *ExecutorInstance, execution *AgentExecution, reason string) {
|
|
execution.remoteInstanceLifecycleMu.Lock()
|
|
defer execution.remoteInstanceLifecycleMu.Unlock()
|
|
execution.agentctlLifecycleMu.Lock()
|
|
defer execution.agentctlLifecycleMu.Unlock()
|
|
m.logger.Warn("rolling back launch execution",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.String("reason", reason))
|
|
if rt != nil && execInstance != nil {
|
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if stopErr := stopRuntimeInstanceAndRelease(cleanupCtx, rt, execInstance, true); stopErr != nil {
|
|
m.logger.Warn("failed to stop runtime instance during launch rollback",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.Error(stopErr))
|
|
}
|
|
}
|
|
if client := execution.currentAgentCtlClient(); client != nil { // protected by agentctlLifecycleMu
|
|
client.Close()
|
|
}
|
|
execution.EndSessionSpan()
|
|
}
|
|
|
|
// rollbackRegisteredLaunch stops the runtime before removing either side of
|
|
// the execution registration. A failed stop retains both in-memory and durable
|
|
// ownership for retry; successful terminal rollback removes the exact row
|
|
// without normal resume-token repair.
|
|
func (m *Manager) rollbackRegisteredLaunch(rt ExecutorBackend, execInstance *ExecutorInstance, execution *AgentExecution, reason string) {
|
|
m.rollbackRegisteredLaunchWithRetryMode(rt, execInstance, execution, false, true, reason)
|
|
}
|
|
|
|
// SetExecutionDescription updates the task description stored in an execution's metadata.
|
|
// This is used when starting an agent on a workspace that was launched without a prompt.
|
|
func (m *Manager) SetExecutionDescription(_ context.Context, executionID string, description string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
execution.setMetadataValue("task_description", description)
|
|
return nil
|
|
}
|
|
|
|
// SetPromptTurnID binds the next prompt completion to a durable Kandev turn.
|
|
// The value is kept on the in-memory execution so it can be snapshotted onto
|
|
// the terminal stream event before AgentReady can admit a successor prompt.
|
|
func (m *Manager) SetPromptTurnID(_ context.Context, executionID, turnID string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
execution.setPromptTurnID(turnID)
|
|
return nil
|
|
}
|
|
|
|
// SetExecutionEnv stores per-run environment variables for the next agent subprocess start.
|
|
func (m *Manager) SetExecutionEnv(_ context.Context, executionID string, env map[string]string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
execution.setMetadataValue("runtime_env", cloneStringMap(env))
|
|
return nil
|
|
}
|
|
|
|
func cloneStringMap(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SetMcpMode changes the MCP tool mode on an existing execution's agentctl instance.
|
|
// This is used when a session transitions to plan/config mode after the workspace was
|
|
// already prepared with the default (task) mode.
|
|
func (m *Manager) SetMcpMode(ctx context.Context, executionID string, mode string) error {
|
|
execution, exists := m.executionStore.Get(executionID)
|
|
if !exists {
|
|
return fmt.Errorf("execution %q not found", executionID)
|
|
}
|
|
client, releaseClient := execution.AcquireAgentCtlClient()
|
|
defer releaseClient()
|
|
if client == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", executionID)
|
|
}
|
|
return client.SetMcpMode(ctx, mode)
|
|
}
|
|
|
|
// SetMcpProvidersForSession replaces the task-mode MCP provider capabilities
|
|
// on the live execution attached to sessionID. The execution store is the
|
|
// source of truth for active agentctl instances; an absent execution is a
|
|
// successful no-op because the next launch or resume derives providers from
|
|
// the persisted task repositories.
|
|
func (m *Manager) SetMcpProvidersForSession(ctx context.Context, sessionID string, providers []string) error {
|
|
if sessionID == "" {
|
|
return fmt.Errorf("session_id is required")
|
|
}
|
|
execution, exists := m.GetExecutionBySessionID(sessionID)
|
|
if !exists || execution == nil {
|
|
m.logger.Debug("MCP provider refresh skipped: no execution for session",
|
|
zap.String("session_id", sessionID))
|
|
return nil
|
|
}
|
|
client, releaseClient := execution.AcquireAgentCtlClient()
|
|
defer releaseClient()
|
|
if client == nil {
|
|
return fmt.Errorf("execution %q has no agentctl client", execution.ID)
|
|
}
|
|
if err := client.SetMcpProviders(ctx, providers); err != nil {
|
|
return fmt.Errorf("set MCP providers for session %s: %w", sessionID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetPluginToolsForAllExecutions pushes a complete revisioned catalog to each
|
|
// live agentctl. One unavailable execution does not prevent the others from
|
|
// converging; stale delivery is rejected by agentctl's snapshot revision.
|
|
func (m *Manager) SetPluginToolsForAllExecutions(ctx context.Context, snapshot plugintools.Snapshot) error {
|
|
var refreshErr error
|
|
for _, execution := range m.ListExecutions() {
|
|
if execution == nil {
|
|
continue
|
|
}
|
|
client, releaseClient := execution.AcquireAgentCtlClient()
|
|
if client == nil {
|
|
continue
|
|
}
|
|
err := client.SetPluginTools(ctx, snapshot)
|
|
releaseClient()
|
|
if err != nil {
|
|
refreshErr = errors.Join(refreshErr, fmt.Errorf("refresh execution %s plugin tools: %w", execution.ID, err))
|
|
}
|
|
}
|
|
return refreshErr
|
|
}
|
|
|
|
// resolveApprovalPolicyAndDisplayName resolves the approval policy and agent display name
|
|
// from the execution's agent profile and registry.
|
|
func (m *Manager) resolveApprovalPolicyAndDisplayName(ctx context.Context, execution *AgentExecution) (string, string) {
|
|
approvalPolicy := ""
|
|
agentDisplayName := ""
|
|
if execution.AgentProfileID == "" || m.profileResolver == nil {
|
|
return approvalPolicy, agentDisplayName
|
|
}
|
|
profileInfo, err := m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID)
|
|
if err != nil {
|
|
return approvalPolicy, agentDisplayName
|
|
}
|
|
if profileInfo.AutoApprove {
|
|
approvalPolicy = "never"
|
|
} else {
|
|
approvalPolicy = "untrusted"
|
|
}
|
|
// Look up display name from registry (e.g. "Claude", "Auggie", "Codex")
|
|
if agentCfg, ok := m.registry.Get(profileInfo.AgentName); ok && agentCfg.DisplayName() != "" {
|
|
agentDisplayName = agentCfg.DisplayName()
|
|
} else {
|
|
agentDisplayName = profileInfo.AgentName
|
|
}
|
|
return approvalPolicy, agentDisplayName
|
|
}
|
|
|
|
// createBootMessage creates a boot message and starts the stderr polling goroutine.
|
|
// Returns nil values when boot messages are unavailable.
|
|
func (m *Manager) createBootMessage(ctx context.Context, execution *AgentExecution, bootCommand, agentDisplayName string) (*models.Message, chan struct{}) {
|
|
if m.bootMessageService == nil || execution == nil {
|
|
return nil, nil
|
|
}
|
|
bootMsg, bootErr := m.bootMessageService.CreateMessage(ctx, &BootMessageRequest{
|
|
TaskSessionID: execution.SessionID,
|
|
TaskID: execution.TaskID,
|
|
Content: "",
|
|
AuthorType: "agent",
|
|
Type: "script_execution",
|
|
Metadata: map[string]interface{}{
|
|
"script_type": "agent_boot",
|
|
"agent_name": agentDisplayName,
|
|
"command": bootCommand,
|
|
"status": "running",
|
|
"is_resuming": execution.ACPSessionID != "",
|
|
"started_at": time.Now().UTC().Format(time.RFC3339Nano),
|
|
},
|
|
})
|
|
if bootErr != nil {
|
|
m.logger.Warn("failed to create boot message, continuing without boot output",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.Error(bootErr))
|
|
return nil, nil
|
|
}
|
|
bootStopCh := make(chan struct{})
|
|
go m.pollAgentStderr(execution, bootMsg, bootStopCh)
|
|
return bootMsg, bootStopCh
|
|
}
|
|
|
|
// getTaskDescriptionFromMetadata extracts the task description string from execution metadata.
|
|
func getTaskDescriptionFromMetadata(execution *AgentExecution) string {
|
|
return execution.metadataString("task_description")
|
|
}
|
|
|
|
// getAttachmentsFromMetadata extracts attachments from execution metadata.
|
|
func getAttachmentsFromMetadata(execution *AgentExecution) []MessageAttachment {
|
|
value, _ := execution.metadataValue("attachments")
|
|
attachments, _ := value.([]MessageAttachment)
|
|
return attachments
|
|
}
|
|
|
|
// configureAndStartAgent configures the agent command and starts the agent subprocess.
|
|
// Returns the effective boot command (full command with adapter args, or base command).
|
|
func (m *Manager) configureAndStartAgent(ctx context.Context, execution *AgentExecution, approvalPolicy string) (string, error) {
|
|
runtimeSnapshot := execution.RuntimeEnvironment()
|
|
metadataEnv := runtimeEnvFromMetadata(execution.MetadataSnapshot())
|
|
var env map[string]string
|
|
if runtimeSnapshot == nil {
|
|
env = cloneStringMap(metadataEnv)
|
|
if env == nil {
|
|
env = make(map[string]string)
|
|
}
|
|
if err := m.mergeAgentProfileEnvForExecution(ctx, execution, env); err != nil {
|
|
m.updateExecutionError(execution.ID, "failed to resolve agent profile environment: "+err.Error())
|
|
return "", fmt.Errorf("resolve agent profile environment: %w", err)
|
|
}
|
|
} else {
|
|
// SetExecutionEnv carries per-run values such as repository credentials.
|
|
// Compose them with the launch snapshot without re-reading profile
|
|
// secrets. Host bridge entries are filtered here, while the normal
|
|
// agentctl Configure boundary composes the request with the instance's
|
|
// canonical environment and preserves inherited user entries.
|
|
var err error
|
|
env, err = composeExecutionRuntimeEnvironment(runtimeSnapshot, metadataEnv)
|
|
if err != nil {
|
|
m.updateExecutionError(execution.ID, "failed to compose agent env: "+err.Error())
|
|
return "", fmt.Errorf("compose agent environment: %w", err)
|
|
}
|
|
}
|
|
if err := spillLargeWakePayloadEnv(env, execution.WorkspacePath, m.logger.Zap()); err != nil {
|
|
m.updateExecutionError(execution.ID, "failed to prepare agent env: "+err.Error())
|
|
return "", fmt.Errorf("failed to prepare agent env: %w", err)
|
|
}
|
|
configureEnv := cloneStringMap(env)
|
|
execution.setRuntimeEnvironment(env)
|
|
client, releaseClient := execution.AcquireAgentCtlClient()
|
|
defer releaseClient()
|
|
if client == nil {
|
|
return "", fmt.Errorf("execution %q has no agentctl client", execution.ID)
|
|
}
|
|
|
|
// Starting with stale agent configuration could expose an old credential
|
|
// set, so the subprocess must not start when configuration delivery fails.
|
|
if err := client.ConfigureAgent(ctx, execution.AgentCommand, execution.AgentArgs, configureEnv, approvalPolicy, execution.ContinueCommand, execution.ContinueArgs); err != nil {
|
|
return "", fmt.Errorf("failed to configure agent: %w", err)
|
|
}
|
|
|
|
fullCommand, err := client.Start(ctx)
|
|
if err != nil {
|
|
m.updateExecutionError(execution.ID, "failed to start agent: "+err.Error())
|
|
return "", fmt.Errorf("failed to start agent: %w", err)
|
|
}
|
|
|
|
bootCommand := fullCommand
|
|
if bootCommand == "" {
|
|
bootCommand = execution.AgentCommand
|
|
}
|
|
return bootCommand, nil
|
|
}
|
|
|
|
func runtimeEnvFromMetadata(metadata map[string]interface{}) map[string]string {
|
|
env := map[string]string{}
|
|
if metadata == nil {
|
|
return env
|
|
}
|
|
if typed, ok := metadata["runtime_env"].(map[string]string); ok {
|
|
for k, v := range typed {
|
|
env[k] = v
|
|
}
|
|
}
|
|
if raw, ok := metadata["runtime_env"].(map[string]interface{}); ok {
|
|
for k, v := range raw {
|
|
if str, strOK := v.(string); strOK {
|
|
env[k] = str
|
|
}
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
// initializeAgentSession handles post-startup initialization: boot message, ACP session,
|
|
// MCP servers. It finalizes the boot message on success or failure.
|
|
func (m *Manager) initializeAgentSession(ctx context.Context, execution *AgentExecution, bootCommand, agentDisplayName, taskDescription, approvalPolicy string) error {
|
|
bootMsg, bootStopCh := m.createBootMessage(ctx, execution, bootCommand, agentDisplayName)
|
|
|
|
// Give the agent process a moment to initialize
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
agentConfig, err := m.getAgentConfigForExecution(execution)
|
|
if err != nil {
|
|
m.finalizeBootMessage(execution, bootMsg, bootStopCh, "failed")
|
|
return fmt.Errorf("failed to get agent config: %w", err)
|
|
}
|
|
|
|
mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig)
|
|
if err != nil {
|
|
m.finalizeBootMessage(execution, bootMsg, bootStopCh, "failed")
|
|
m.updateExecutionError(execution.ID, "failed to resolve MCP config: "+err.Error())
|
|
return fmt.Errorf("failed to resolve MCP config: %w", err)
|
|
}
|
|
|
|
attachments := getAttachmentsFromMetadata(execution)
|
|
if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, attachments, mcpServers); err != nil {
|
|
attempted, retryErr := m.retryManagedRuntimeStartup(
|
|
ctx,
|
|
execution,
|
|
err,
|
|
agentConfig,
|
|
approvalPolicy,
|
|
taskDescription,
|
|
attachments,
|
|
mcpServers,
|
|
)
|
|
if attempted {
|
|
if retryErr == nil {
|
|
m.finalizeBootMessage(execution, bootMsg, bootStopCh, containerStateExited)
|
|
return nil
|
|
}
|
|
err = retryErr
|
|
} else if retryErr != nil {
|
|
err = retryErr
|
|
}
|
|
m.finalizeBootMessage(execution, bootMsg, bootStopCh, "failed")
|
|
m.updateExecutionError(execution.ID, "failed to initialize ACP: "+err.Error())
|
|
return fmt.Errorf("failed to initialize ACP: %w", err)
|
|
}
|
|
|
|
m.finalizeBootMessage(execution, bootMsg, bootStopCh, containerStateExited)
|
|
return nil
|
|
}
|
|
|
|
// initGitRepo initializes a git repository in the given directory.
|
|
// Creates an initial commit so the workspace has a clean git state.
|
|
// Existing repositories are reused and keep Kandev metadata out of git status.
|
|
func (m *Manager) initGitRepo(ctx context.Context, workspacePath string) error {
|
|
gitDir := filepath.Join(workspacePath, ".git")
|
|
if info, err := os.Lstat(gitDir); err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return fmt.Errorf("invalid git directory: %s", gitDir)
|
|
}
|
|
return excludeWorkspaceOwnershipMarker(gitDir)
|
|
} else if !os.IsNotExist(err) {
|
|
// Non-ENOENT error (permissions, I/O, etc.) - fail explicitly
|
|
return fmt.Errorf("failed to check for .git directory: %w", err)
|
|
}
|
|
|
|
// Initialize git repository
|
|
cmd := subproc.NewGitCommand(ctx, "init")
|
|
cmd.Dir = workspacePath
|
|
if output, err := subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, cmd); err != nil {
|
|
return fmt.Errorf("git init failed: %w (output: %s)", err, string(output))
|
|
}
|
|
|
|
// Configure git user (required for initial commit)
|
|
configName := subproc.NewGitCommand(ctx, "config", "user.name", "Kandev Quick Chat")
|
|
configName.Dir = workspacePath
|
|
_ = subproc.RunGitClass(ctx, subproc.GitLifecycle, configName) // Ignore error - might already be configured globally
|
|
|
|
configEmail := subproc.NewGitCommand(ctx, "config", "user.email", "quickchat@kandev.local")
|
|
configEmail.Dir = workspacePath
|
|
_ = subproc.RunGitClass(ctx, subproc.GitLifecycle, configEmail) // Ignore error - might already be configured globally
|
|
|
|
// Create initial commit with empty .gitkeep file
|
|
gitkeepPath := filepath.Join(workspacePath, ".gitkeep")
|
|
if err := os.WriteFile(gitkeepPath, []byte(""), 0644); err != nil {
|
|
return fmt.Errorf("failed to create .gitkeep: %w", err)
|
|
}
|
|
|
|
addCmd := subproc.NewGitCommand(ctx, "add", ".gitkeep")
|
|
addCmd.Dir = workspacePath
|
|
if output, err := subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, addCmd); err != nil {
|
|
return fmt.Errorf("git add failed: %w (output: %s)", err, string(output))
|
|
}
|
|
|
|
commitCmd := subproc.NewGitCommand(ctx, "commit", "-m", "Initial commit")
|
|
commitCmd.Dir = workspacePath
|
|
if output, err := subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, commitCmd); err != nil {
|
|
return fmt.Errorf("git commit failed: %w (output: %s)", err, string(output))
|
|
}
|
|
|
|
return excludeWorkspaceOwnershipMarker(gitDir)
|
|
}
|