package infra import ( "fmt" "os" "os/exec" "strings" "time" ) // WorktreeLaunchConfig configures a headless Claude agent run inside a fresh // git worktree. All paths must be absolute. The function spawns claude in the // background and returns immediately with the PID; the caller is responsible // for AgentCleanupWorktree when the run finishes (or aborts). type WorktreeLaunchConfig struct { RepoRoot string // absolute path to the main repo (git -C ) Branch string // e.g. "auto/0115-foo" — created from master WorktreePath string // absolute path where worktree gets added Prompt string // text passed to claude -p LogPath string // file claude stdout/stderr is redirected to Env map[string]string // extra env vars merged on top of os.Environ() SkipPerms bool // adds --dangerously-skip-permissions ResetIfExists bool // if true, branch + worktree are nuked first } // WorktreeLaunchResult is the return shape of AgentLaunchWorktree. type WorktreeLaunchResult struct { PID int // claude process id (0 if Error != "") Branch string // echoes cfg.Branch WorktreePath string // echoes cfg.WorktreePath LogPath string // echoes cfg.LogPath StartedAt int64 // unix seconds when cmd.Start() returned Error string // empty on success; populated on any failure } // AgentLaunchWorktree creates a fresh git worktree on a new branch off master // and spawns `claude -p ` headless inside that worktree, redirecting // stdout+stderr to LogPath. Returns immediately (process keeps running). // // If `claude` is not in PATH, falls back to an `echo` stub so tests can run // without the real binary — the stub still produces a real PID and log file. // // Impure: touches the filesystem (worktree), spawns a process, writes a log. func AgentLaunchWorktree(cfg WorktreeLaunchConfig) WorktreeLaunchResult { res := WorktreeLaunchResult{ Branch: cfg.Branch, WorktreePath: cfg.WorktreePath, LogPath: cfg.LogPath, } if cfg.RepoRoot == "" || cfg.Branch == "" || cfg.WorktreePath == "" { res.Error = "RepoRoot, Branch and WorktreePath are required" return res } // Best-effort cleanup of pre-existing branch/worktree. if cfg.ResetIfExists { _ = exec.Command("git", "-C", cfg.RepoRoot, "worktree", "remove", "--force", cfg.WorktreePath).Run() _ = exec.Command("git", "-C", cfg.RepoRoot, "branch", "-D", cfg.Branch).Run() // Best-effort dir cleanup (git worktree remove leaves nothing, but // just in case the dir was created out-of-band). _ = os.RemoveAll(cfg.WorktreePath) } // Create the new worktree off master. addCmd := exec.Command("git", "-C", cfg.RepoRoot, "worktree", "add", cfg.WorktreePath, "-b", cfg.Branch, "master") if out, err := addCmd.CombinedOutput(); err != nil { res.Error = fmt.Sprintf("git worktree add failed: %v: %s", err, strings.TrimSpace(string(out))) return res } // Open / truncate log file. logFile, err := os.OpenFile(cfg.LogPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { res.Error = fmt.Sprintf("open log %s: %v", cfg.LogPath, err) return res } // Resolve claude binary; fall back to echo stub if not found. claudeBin, lookErr := exec.LookPath("claude") var args []string var bin string if lookErr != nil { bin = "echo" args = []string{"STUB: claude not in PATH, prompt was:", cfg.Prompt} } else { bin = claudeBin if cfg.SkipPerms { args = append(args, "--dangerously-skip-permissions") } args = append(args, "-p", cfg.Prompt) } cmd := exec.Command(bin, args...) cmd.Dir = cfg.WorktreePath cmd.Stdout = logFile cmd.Stderr = logFile // Merge env: os.Environ() base + cfg.Env overrides. env := os.Environ() for k, v := range cfg.Env { env = append(env, k+"="+v) } cmd.Env = env if err := cmd.Start(); err != nil { _ = logFile.Close() res.Error = fmt.Sprintf("cmd.Start: %v", err) return res } // Release the file handle in this process — child still holds it. // (Closing immediately is OK; the kernel keeps the fd open in the child.) go func() { _ = cmd.Wait() _ = logFile.Close() }() res.PID = cmd.Process.Pid res.StartedAt = time.Now().Unix() return res }