Files
agent_runner_api/agent_spawn.go
T

144 lines
3.9 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type SpawnConfig struct {
RepoRoot string
Branch string
WorktreePath string
Prompt string
LogPath string
}
type SpawnResult struct {
PID int `json:"pid"`
Branch string `json:"branch"`
WorktreePath string `json:"worktree_path"`
LogPath string `json:"log_path"`
StartedAt int64 `json:"started_at"`
Error string `json:"error,omitempty"`
}
// Spawn creates a git worktree on a fresh branch (reset if exists) and starts
// the claude headless subprocess. If claude is not in PATH or
// AGENT_RUNNER_STUB=1, runs `echo STUB: <prompt>` as a placeholder.
func Spawn(cfg SpawnConfig) SpawnResult {
res := SpawnResult{
Branch: cfg.Branch,
WorktreePath: cfg.WorktreePath,
LogPath: cfg.LogPath,
StartedAt: time.Now().Unix(),
}
if cfg.RepoRoot == "" || cfg.Branch == "" || cfg.WorktreePath == "" {
res.Error = "missing required fields (repo_root/branch/worktree_path)"
return res
}
// Ensure parent dir for worktree exists
if err := os.MkdirAll(filepath.Dir(cfg.WorktreePath), 0o755); err != nil {
res.Error = "mkdir worktree parent: " + err.Error()
return res
}
// If worktree path already exists, remove forcibly first
if _, err := os.Stat(cfg.WorktreePath); err == nil {
_ = exec.Command("git", "-C", cfg.RepoRoot, "worktree", "remove", "--force", cfg.WorktreePath).Run()
_ = os.RemoveAll(cfg.WorktreePath)
}
// Delete branch if exists (best-effort)
_ = exec.Command("git", "-C", cfg.RepoRoot, "branch", "-D", cfg.Branch).Run()
// Create worktree on new branch from master (fallback to current HEAD if master missing)
base := "master"
if err := exec.Command("git", "-C", cfg.RepoRoot, "rev-parse", "--verify", base).Run(); err != nil {
base = "HEAD"
}
cmd := exec.Command("git", "-C", cfg.RepoRoot, "worktree", "add", "-b", cfg.Branch, cfg.WorktreePath, base)
out, err := cmd.CombinedOutput()
if err != nil {
res.Error = fmt.Sprintf("worktree add: %s: %s", err.Error(), string(out))
return res
}
// Open log
if cfg.LogPath == "" {
cfg.LogPath = filepath.Join(cfg.WorktreePath, "agent.log")
res.LogPath = cfg.LogPath
}
if err := os.MkdirAll(filepath.Dir(cfg.LogPath), 0o755); err != nil {
res.Error = "mkdir log: " + err.Error()
return res
}
logFile, err := os.OpenFile(cfg.LogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
res.Error = "open log: " + err.Error()
return res
}
// Decide command: claude or stub
useStub := os.Getenv("AGENT_RUNNER_STUB") == "1"
if !useStub {
if _, err := exec.LookPath("claude"); err != nil {
useStub = true
}
}
var sub *exec.Cmd
if useStub {
sub = exec.Command("echo", "STUB:", cfg.Prompt)
} else {
sub = exec.Command("claude", "--headless", "--dangerously-skip-permissions", "-p", cfg.Prompt)
sub.Dir = cfg.WorktreePath
}
if sub.Dir == "" {
sub.Dir = cfg.WorktreePath
}
sub.Stdout = logFile
sub.Stderr = logFile
if err := sub.Start(); err != nil {
logFile.Close()
res.Error = "spawn: " + err.Error()
return res
}
res.PID = sub.Process.Pid
// Reap async — closes log when subprocess exits
go func() {
_ = sub.Wait()
_ = logFile.Close()
}()
return res
}
// Cleanup kills the PID (best-effort), removes the worktree and deletes the branch.
func Cleanup(repoRoot string, pid int, worktreePath, branch string) error {
var firstErr error
if pid > 0 {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Kill()
}
}
if worktreePath != "" {
out, err := exec.Command("git", "-C", repoRoot, "worktree", "remove", "--force", worktreePath).CombinedOutput()
if err != nil && !strings.Contains(string(out), "is not a working tree") {
firstErr = fmt.Errorf("worktree remove: %s: %s", err.Error(), string(out))
}
_ = os.RemoveAll(worktreePath)
}
if branch != "" {
_ = exec.Command("git", "-C", repoRoot, "branch", "-D", branch).Run()
}
return firstErr
}