Files
fn_registry/functions/infra/agent_launch_worktree.go
T
egutierrez a87c992cd9 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.
2026-05-18 18:24:08 +02:00

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
}