feat(infra): agent_launch_worktree + agent_cleanup_worktree Go fns
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.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user