a87c992cd9
Two Go functions in functions/infra/ for orchestrating headless Claude agents inside isolated git worktrees: - AgentLaunchWorktree(cfg): creates worktree off master, spawns claude -p in background, redirects stdout/stderr to LogPath. Falls back to echo stub when claude binary missing (CI/test friendly). ResetIfExists support for re-runs. - AgentCleanupWorktree(repo, branch, path, pid): SIGTERM with 1s grace then SIGKILL, git worktree remove --force, git branch -D. Best-effort: only errors when all three steps fail (idempotent cleanup-twice). Promotes inline bash from .claude/skills/parallel-fix-issues/ and fn-orquestador to first-class registry functions. Closes issue 0115. Capability group: agents.
122 lines
4.2 KiB
Go
122 lines
4.2 KiB
Go
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 <RepoRoot>)
|
|
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 <prompt>` 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
|
|
}
|