144 lines
3.9 KiB
Go
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
|
|
}
|