Files
kandev/apps/backend/internal/agent/lifecycle/manager_launch.go
T

702 lines
26 KiB
Go

package lifecycle
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/task/models"
)
// 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) {
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", req.AgentProfileID))
return req.AgentProfileID, nil, nil
}
profileInfo, err := m.profileResolver.ResolveProfile(ctx, req.AgentProfileID)
if err != nil {
return "", nil, fmt.Errorf("failed to resolve agent profile: %w", err)
}
m.logger.Debug("resolved agent profile",
zap.String("profile_id", req.AgentProfileID),
zap.String("agent_name", profileInfo.AgentName),
zap.String("agent_type", profileInfo.AgentName))
return profileInfo.AgentName, profileInfo, nil
}
// buildLaunchMetadata builds runtime metadata for the Launch request.
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
}
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
}
return metadata
}
// agentCommands holds the initial and continue command strings for an agent execution.
type agentCommands struct {
initial string
continue_ string // continue command for one-shot agents (empty if not applicable)
}
// buildAgentCommand builds the agent command strings for the execution.
// Returns both the initial command and the continue command (for one-shot agents like Amp).
func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfileInfo, agentConfig agents.Agent) agentCommands {
model := ""
autoApprove := false
permissionValues := make(map[string]bool)
if profileInfo != nil {
model = profileInfo.Model
autoApprove = profileInfo.AutoApprove
permissionValues["auto_approve"] = profileInfo.AutoApprove
permissionValues["allow_indexing"] = profileInfo.AllowIndexing
permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions
}
// Allow model override from request (for dynamic model switching)
if req.ModelOverride != "" {
model = req.ModelOverride
}
// 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,
}
return agentCommands{
initial: m.commandBuilder.BuildCommandString(agentConfig, cmdOpts),
continue_: m.commandBuilder.BuildContinueCommandString(agentConfig, cmdOpts),
}
}
// 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.
func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchRequest) (workspacePath, mainRepoGitDir, worktreeID, worktreeBranch string) {
if req.UseWorktree {
// Worktree executors: preparer handles worktree creation.
// Return empty path; it will be populated from preparer results.
return "", "", "", ""
}
workspacePath = req.WorkspacePath
if req.RepositoryPath != "" && workspacePath == "" {
workspacePath = req.RepositoryPath
}
// For tasks without repositories (e.g., quick chat), create a workspace in ~/.kandev/quick-chat/
// These directories are cleaned up when the ephemeral task is deleted (see task service performTaskCleanup).
if workspacePath == "" && req.SessionID != "" && m.dataDir != "" {
quickChatDir := filepath.Join(m.dataDir, "quick-chat")
if err := os.MkdirAll(quickChatDir, 0755); err != nil {
m.logger.Warn("failed to create quick-chat directory, continuing without workspace",
zap.String("session_id", req.SessionID),
zap.String("quick_chat_dir", quickChatDir),
zap.Error(err))
return "", "", "", ""
}
// Validate SessionID doesn't contain path separators (security: prevent path traversal)
if strings.ContainsAny(req.SessionID, `/\`) {
m.logger.Warn("session ID contains path separator, rejecting",
zap.String("session_id", req.SessionID))
return "", "", "", ""
}
// Use session ID as directory name for easy cleanup
workspacePath = filepath.Join(quickChatDir, req.SessionID)
if err := os.MkdirAll(workspacePath, 0755); err != nil {
m.logger.Warn("failed to create session workspace, continuing without workspace",
zap.String("session_id", req.SessionID),
zap.String("workspace_path", workspacePath),
zap.Error(err))
return "", "", "", ""
}
// Initialize git repository in the workspace (if not already initialized)
if err := m.initGitRepo(ctx, workspacePath); err != nil {
m.logger.Warn("failed to initialize git repository in quick chat workspace",
zap.String("session_id", req.SessionID),
zap.String("workspace_path", workspacePath),
zap.Error(err))
// Continue anyway - git is optional for quick chat
}
m.logger.Info("created quick chat workspace",
zap.String("session_id", req.SessionID),
zap.String("workspace_path", workspacePath))
}
return
}
// launchPrepareRequest copies the launch request, sets the resolved workspace path,
// populates metadata from the request fields, and injects profile environment variables.
func (m *Manager) launchPrepareRequest(req *LaunchRequest, profileInfo *AgentProfileInfo, workspacePath string) (LaunchRequest, string) {
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 profileInfo != nil {
if reqWithWorktree.Env == nil {
reqWithWorktree.Env = make(map[string]string)
}
if profileInfo.Model != "" {
reqWithWorktree.Env["AGENT_MODEL"] = profileInfo.Model
}
if profileInfo.AutoApprove {
reqWithWorktree.Env["AGENTCTL_AUTO_APPROVE_PERMISSIONS"] = "true"
}
}
return reqWithWorktree, executionID
}
// 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,
StepIndex: stepIndex,
TotalSteps: totalSteps,
Status: string(step.Status),
Output: step.Output,
Error: step.Error,
Warning: step.Warning,
WarningDetail: step.WarningDetail,
})
}
}
// 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, mainRepoGitDir, worktreeID, worktreeBranch string) (*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 := m.buildEnvForExecution(executionID, reqWithWorktree, agentConfig)
acpMcpServers, err := m.resolveMcpServersWithParams(ctx, reqWithWorktree.AgentProfileID, 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)
execReq := &ExecutorCreateRequest{
InstanceID: executionID,
TaskID: reqWithWorktree.TaskID,
SessionID: reqWithWorktree.SessionID,
AgentProfileID: reqWithWorktree.AgentProfileID,
WorkspacePath: reqWithWorktree.WorkspacePath,
Protocol: string(agentConfig.Runtime().Protocol),
Env: env,
Metadata: metadata,
AgentConfig: agentConfig,
McpServers: mcpServers,
PreviousExecutionID: reqWithWorktree.PreviousExecutionID,
McpMode: reqWithWorktree.McpMode,
OnProgress: m.newProgressCallback(reqWithWorktree.TaskID, reqWithWorktree.SessionID),
}
if resumer, ok := rt.(RemoteSessionResumer); ok {
if err := resumer.ResumeRemoteInstance(ctx, execReq); err != nil {
return nil, nil, nil, fmt.Errorf("failed remote resume preflight: %w", err)
}
}
execInstance, err := rt.CreateInstance(ctx, execReq)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to create execution: %w", err)
}
return execReq, execInstance, rt, 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 {
if m.preparerRegistry == nil {
return nil
}
// Look up preparer by the raw executor type first (e.g. "worktree"),
// then fall back to the backend name (e.g. "standalone").
// This allows executor types that share a backend (local and worktree
// both map to standalone) to have distinct preparation logic.
execName := executor.Name(req.ExecutorType)
preparer := m.preparerRegistry.Get(execName)
if preparer == nil {
execName = executor.ExecutorTypeToBackend(models.ExecutorType(req.ExecutorType))
preparer = m.preparerRegistry.Get(execName)
}
if preparer == nil {
return nil
}
// 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 := &EnvPrepareRequest{
TaskID: req.TaskID,
SessionID: req.SessionID,
TaskTitle: req.TaskTitle,
ExecutorType: execName,
WorkspacePath: workspacePath,
RepositoryPath: req.RepositoryPath,
RepositoryID: req.RepositoryID,
UseWorktree: req.UseWorktree,
SetupScript: req.SetupScript,
BaseBranch: req.BaseBranch,
CheckoutBranch: req.CheckoutBranch,
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
PullBeforeWorktree: req.PullBeforeWorktree,
Env: req.Env,
}
result, err := preparer.Prepare(ctx, prepReq, m.newProgressCallback(req.TaskID, req.SessionID))
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(),
}
}
return result
}
// launchApplyPrepareResult applies workspace metadata from the preparer result and publishes completion.
// 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,
})
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
}
m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{
TaskID: req.TaskID,
SessionID: req.SessionID,
Success: true,
DurationMs: result.Duration.Milliseconds(),
WorkspacePath: result.WorkspacePath,
Steps: result.Steps,
})
return nil
}
// Launch launches a new agent for a task
func (m *Manager) Launch(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)
}
// 3. Check if session already has an agent running
if req.SessionID != "" {
if existingExecution, exists := m.executionStore.GetBySessionID(req.SessionID); exists {
return nil, fmt.Errorf("session %q already has an agent running (execution: %s)", req.SessionID, existingExecution.ID)
}
}
// 4. Resolve workspace path (non-worktree executors use this directly)
workspacePath, mainRepoGitDir, worktreeID, worktreeBranch := m.launchResolveWorkspacePath(ctx, req)
// 4b. Run environment preparation (if preparer registered for this executor type).
// For worktree executors, the preparer creates the worktree and returns workspace metadata.
prepResult := m.runEnvironmentPreparer(ctx, req, workspacePath)
if prepResult != nil {
if err := m.launchApplyPrepareResult(req, prepResult, &workspacePath, &mainRepoGitDir, &worktreeID, &worktreeBranch); err != nil {
return nil, err
}
}
// 5 & 6. Prepare the request copy with metadata and profile env
reqWithWorktree, executionID := m.launchPrepareRequest(req, profileInfo, workspacePath)
// 7. Build runtime request and create instance (agent not started yet)
execReq, execInstance, rt, err := m.launchBuildExecutorRequest(ctx, executionID, &reqWithWorktree, agentConfig, mainRepoGitDir, worktreeID, worktreeBranch)
if err != nil {
m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{
TaskID: req.TaskID,
SessionID: req.SessionID,
Success: false,
ErrorMessage: err.Error(),
})
return nil, err
}
// Publish PrepareCompleted for CreateInstance only if no preparer ran
// (preparers publish their own completion above).
if prepResult == nil {
m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{
TaskID: req.TaskID,
SessionID: req.SessionID,
Success: true,
DurationMs: 0,
})
}
// Convert to AgentExecution and set the runtime name
execution := execInstance.ToAgentExecution(execReq)
execution.RuntimeName = string(rt.Name())
if req.ACPSessionID != "" {
execution.ACPSessionID = req.ACPSessionID
}
if req.PreviousExecutionID != "" {
execution.isResumedSession = true
}
cmds := m.buildAgentCommand(req, profileInfo, agentConfig)
execution.AgentCommand = cmds.initial
execution.ContinueCommand = cmds.continue_
// 8. Track the execution
m.executionStore.Add(execution)
go m.pollOneRemoteStatus(context.Background(), execution)
// 9. Publish agent.started event
m.eventPublisher.PublishAgentEvent(ctx, events.AgentStarted, execution)
m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlStarting, execution, "")
// 10. 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)
m.logger.Debug("agentctl execution created (agent not started)",
zap.String("execution_id", executionID),
zap.String("task_id", req.TaskID),
zap.String("runtime", execution.RuntimeName))
return execution, nil
}
// 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)
}
if execution.Metadata == nil {
execution.Metadata = make(map[string]interface{})
}
execution.Metadata["task_description"] = description
return nil
}
// 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)
}
if execution.agentctl == nil {
return fmt.Errorf("execution %q has no agentctl client", executionID)
}
return execution.agentctl.SetMcpMode(ctx, mode)
}
// 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 the message and stop channel (both nil if bootMessageService is not configured).
func (m *Manager) createBootMessage(ctx context.Context, execution *AgentExecution, bootCommand, agentDisplayName string) (*models.Message, chan struct{}) {
if m.bootMessageService == 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, execution.agentctl, bootMsg, bootStopCh)
return bootMsg, bootStopCh
}
// getTaskDescriptionFromMetadata extracts the task description string from execution metadata.
func getTaskDescriptionFromMetadata(execution *AgentExecution) string {
if execution.Metadata == nil {
return ""
}
if desc, ok := execution.Metadata["task_description"].(string); ok {
return desc
}
return ""
}
// getAttachmentsFromMetadata extracts attachments from execution metadata.
func getAttachmentsFromMetadata(execution *AgentExecution) []MessageAttachment {
if execution.Metadata == nil {
return nil
}
attachments, ok := execution.Metadata["attachments"].([]MessageAttachment)
if ok {
return attachments
}
return nil
}
// 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, taskDescription, approvalPolicy string) (string, error) {
env := map[string]string{}
if taskDescription != "" {
env["TASK_DESCRIPTION"] = taskDescription
}
if err := execution.agentctl.ConfigureAgent(ctx, execution.AgentCommand, env, approvalPolicy, execution.ContinueCommand); err != nil {
return "", fmt.Errorf("failed to configure agent: %w", err)
}
fullCommand, err := execution.agentctl.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
}
// 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 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, execution.agentctl, "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, execution.agentctl, "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 {
m.finalizeBootMessage(execution, bootMsg, bootStopCh, execution.agentctl, "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, execution.agentctl, containerStateExited)
return nil
}
// initGitRepo initializes a git repository in the given directory.
// Creates an initial commit so the workspace has a clean git state.
// This function is idempotent - it skips initialization if .git already exists.
func (m *Manager) initGitRepo(ctx context.Context, workspacePath string) error {
// Check if git repository already exists (idempotent)
gitDir := filepath.Join(workspacePath, ".git")
if info, err := os.Stat(gitDir); err == nil {
if info.IsDir() {
return nil // Already initialized
}
} 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 := exec.CommandContext(ctx, "git", "init")
cmd.Dir = workspacePath
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git init failed: %w (output: %s)", err, string(output))
}
// Configure git user (required for initial commit)
configName := exec.CommandContext(ctx, "git", "config", "user.name", "Kandev Quick Chat")
configName.Dir = workspacePath
_ = configName.Run() // Ignore error - might already be configured globally
configEmail := exec.CommandContext(ctx, "git", "config", "user.email", "quickchat@kandev.local")
configEmail.Dir = workspacePath
_ = configEmail.Run() // 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 := exec.CommandContext(ctx, "git", "add", ".gitkeep")
addCmd.Dir = workspacePath
if output, err := addCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git add failed: %w (output: %s)", err, string(output))
}
commitCmd := exec.CommandContext(ctx, "git", "commit", "-m", "Initial commit")
commitCmd.Dir = workspacePath
if output, err := commitCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git commit failed: %w (output: %s)", err, string(output))
}
return nil
}