feat: initial scaffold of agent_runner_api service Go :8486
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user