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: ` 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 }