summaryrefslogtreecommitdiff
path: root/internal/executor/gemini.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-25 18:59:06 +0000
committerClaude <noreply@anthropic.com>2026-05-25 18:59:06 +0000
commitc0fabf5a2f4ca371403571b82e29c5073bed24fb (patch)
tree40a1e5d63f75f382c41e428e87558bae9be466b9 /internal/executor/gemini.go
parent5669e2ca58b4a99616083eaff9f639ecce4ba809 (diff)
refactor(executor): delete the dead GeminiRunner stub and its sandbox helpers (Phase 4)
The production "gemini" agent type is served by ContainerRunner (Docker), not by the standalone subprocess GeminiRunner, which was never wired into serve.go and returned canned/simulated output — the dangerous landmine called out in CLAUDE.md's design debt. Removing it (gemini.go + tests). The subprocess sandbox helpers (setupSandbox/teardownSandbox/sandboxCloneSource in sandbox.go), preserved in Phase 2 only because GeminiRunner used them, are now orphaned — ContainerRunner has its own git/workspace handling — so they go too. No production behavior change: nothing instantiated GeminiRunner. Build green; executor tests unchanged except the pre-existing git commit-signing failures. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/executor/gemini.go')
-rw-r--r--internal/executor/gemini.go346
1 files changed, 0 insertions, 346 deletions
diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go
deleted file mode 100644
index d229361..0000000
--- a/internal/executor/gemini.go
+++ /dev/null
@@ -1,346 +0,0 @@
-package executor
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "log/slog"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
-
- "github.com/thepeterstone/claudomator/internal/storage"
- "github.com/thepeterstone/claudomator/internal/task"
-)
-
-// GeminiRunner spawns the `gemini` CLI in non-interactive mode.
-type GeminiRunner struct {
- BinaryPath string // defaults to "gemini"
- Logger *slog.Logger
- LogDir string // base directory for execution logs
- APIURL string // base URL of the Claudomator API, passed to subprocesses
-}
-
-// ExecLogDir returns the log directory for the given execution ID.
-func (r *GeminiRunner) ExecLogDir(execID string) string {
- if r.LogDir == "" {
- return ""
- }
- return filepath.Join(r.LogDir, execID)
-}
-
-func (r *GeminiRunner) binaryPath() string {
- if r.BinaryPath != "" {
- return r.BinaryPath
- }
- return "gemini"
-}
-
-// Run executes the gemini CLI inside a sandboxed clone of project_dir.
-// When project_dir is set, claudomator first clones it into a temp sandbox
-// (preferring a `local` bare remote, then `origin`, then the working tree)
-// and runs the agent there. On success the sandbox is autocommitted and
-// pushed back to origin/master, then removed. On failure the sandbox is
-// preserved and its path is included in the returned error so the user can
-// inspect partial work. If the agent writes a question file before exiting,
-// Run returns *BlockedError with SandboxDir populated so a resume execution
-// can pick up in the same directory.
-func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error {
- projectDir := t.Agent.ProjectDir
-
- if projectDir != "" {
- if _, err := os.Stat(projectDir); err != nil {
- return fmt.Errorf("project_dir %q: %w", projectDir, err)
- }
- }
-
- logDir := r.ExecLogDir(e.ID)
- if logDir == "" {
- logDir = e.ID
- }
- if err := os.MkdirAll(logDir, 0700); err != nil {
- return fmt.Errorf("creating log dir: %w", err)
- }
-
- if e.StdoutPath == "" {
- e.StdoutPath = filepath.Join(logDir, "stdout.log")
- e.StderrPath = filepath.Join(logDir, "stderr.log")
- e.ArtifactDir = logDir
- }
-
- if e.SessionID == "" {
- if e.ResumeSessionID != "" {
- e.SessionID = e.ResumeSessionID
- } else {
- e.SessionID = e.ID
- }
- }
-
- // Sandbox setup: for new executions with a project_dir, clone into a sandbox.
- // Resume executions reuse the preserved sandbox so any partial work survives.
- // If the preserved sandbox is missing (e.g. /tmp was purged), clone fresh.
- var sandboxDir string
- var startHEAD string
- effectiveWorkingDir := projectDir
- if e.ResumeSessionID != "" {
- if e.SandboxDir != "" {
- if _, statErr := os.Stat(e.SandboxDir); statErr == nil {
- effectiveWorkingDir = e.SandboxDir
- } else {
- r.Logger.Warn("preserved sandbox missing, cloning fresh", "sandbox", e.SandboxDir, "project_dir", projectDir)
- e.SandboxDir = ""
- if projectDir != "" {
- var err error
- sandboxDir, err = setupSandbox(projectDir, r.Logger)
- if err != nil {
- return fmt.Errorf("setting up sandbox: %w", err)
- }
- effectiveWorkingDir = sandboxDir
- r.Logger.Info("fresh sandbox created for resume", "sandbox", sandboxDir, "project_dir", projectDir)
- }
- }
- }
- } else if projectDir != "" {
- var err error
- sandboxDir, err = setupSandbox(projectDir, r.Logger)
- if err != nil {
- return fmt.Errorf("setting up sandbox: %w", err)
- }
- effectiveWorkingDir = sandboxDir
- r.Logger.Info("sandbox created", "sandbox", sandboxDir, "project_dir", projectDir)
- }
-
- if effectiveWorkingDir != "" {
- headOut, _ := exec.Command("git", gitSafe("-C", effectiveWorkingDir, "rev-parse", "HEAD")...).Output()
- startHEAD = strings.TrimSpace(string(headOut))
- }
-
- questionFile := filepath.Join(logDir, "question.json")
- args := r.buildArgs(t, e, questionFile)
-
- if err := r.execOnce(ctx, args, effectiveWorkingDir, projectDir, e); err != nil {
- if sandboxDir != "" {
- return fmt.Errorf("%w (sandbox preserved at %s)", err, sandboxDir)
- }
- return err
- }
-
- // Check whether the agent left a question before exiting.
- data, readErr := os.ReadFile(questionFile)
- if readErr == nil {
- os.Remove(questionFile)
- questionJSON := strings.TrimSpace(string(data))
- if isCompletionReport(questionJSON) {
- r.Logger.Info("treating question file as completion report", "taskID", e.TaskID)
- _ = ch.ReportSummary(ctx, extractQuestionText(questionJSON))
- } else {
- // Preserve sandbox on BLOCKED so a resume can pick up in the same dir.
- return &BlockedError{QuestionJSON: questionJSON, SessionID: e.SessionID, SandboxDir: sandboxDir}
- }
- }
-
- // Read agent summary if written.
- summaryFile := filepath.Join(logDir, "summary.txt")
- if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil {
- os.Remove(summaryFile)
- _ = ch.ReportSummary(ctx, strings.TrimSpace(string(summaryData)))
- }
-
- // Merge sandbox back to project_dir and clean up.
- if sandboxDir != "" {
- if mergeErr := teardownSandbox(projectDir, sandboxDir, startHEAD, r.Logger, e); mergeErr != nil {
- return fmt.Errorf("sandbox teardown: %w (sandbox preserved at %s)", mergeErr, sandboxDir)
- }
- }
- return nil
-}
-
-func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error {
- cmd := exec.CommandContext(ctx, r.binaryPath(), args...)
- cmd.Env = append(os.Environ(),
- "CLAUDOMATOR_API_URL="+r.APIURL,
- "CLAUDOMATOR_TASK_ID="+e.TaskID,
- "CLAUDOMATOR_PROJECT_DIR="+projectDir,
- "CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"),
- "CLAUDOMATOR_SUMMARY_FILE="+filepath.Join(e.ArtifactDir, "summary.txt"),
- )
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- if workingDir != "" {
- cmd.Dir = workingDir
- }
-
- stdoutFile, err := os.Create(e.StdoutPath)
- if err != nil {
- return fmt.Errorf("creating stdout log: %w", err)
- }
- defer stdoutFile.Close()
-
- stderrFile, err := os.Create(e.StderrPath)
- if err != nil {
- return fmt.Errorf("creating stderr log: %w", err)
- }
- defer stderrFile.Close()
-
- stdoutR, stdoutW, err := os.Pipe()
- if err != nil {
- return fmt.Errorf("creating stdout pipe: %w", err)
- }
- cmd.Stdout = stdoutW
- cmd.Stderr = stderrFile
-
- if err := cmd.Start(); err != nil {
- stdoutW.Close()
- stdoutR.Close()
- return fmt.Errorf("starting gemini: %w", err)
- }
- stdoutW.Close()
-
- killDone := make(chan struct{})
- go func() {
- select {
- case <-ctx.Done():
- syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
- case <-killDone:
- }
- }()
-
- var streamCost float64
- var streamErr error
- var wg sync.WaitGroup
- wg.Add(1)
- go func() {
- defer wg.Done()
- streamCost, streamErr = parseGeminiStream(stdoutR, stdoutFile, r.Logger)
- stdoutR.Close()
- }()
-
- waitErr := cmd.Wait()
- close(killDone)
- wg.Wait()
-
- if streamCost > 0 {
- e.CostUSD = streamCost
- }
-
- if waitErr != nil {
- if exitErr, ok := waitErr.(*exec.ExitError); ok {
- e.ExitCode = exitErr.ExitCode()
- }
- if streamErr != nil {
- return streamErr
- }
- if tail := tailFile(e.StderrPath, 20); tail != "" {
- return fmt.Errorf("gemini exited with error: %w\nstderr:\n%s", waitErr, tail)
- }
- return fmt.Errorf("gemini exited with error: %w", waitErr)
- }
-
- if streamErr != nil {
- return streamErr
- }
- return nil
-}
-
-// parseGeminiStream reads streaming JSON from the gemini CLI, strips markdown
-// code fences if the output is wrapped in them, writes the inner stream-json
-// to w, and returns (costUSD, error). If a `result` event has `is_error: true`,
-// an error wrapping the result message is returned.
-func parseGeminiStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, error) {
- fullOutput, err := io.ReadAll(r)
- if err != nil {
- return 0, fmt.Errorf("reading full gemini output: %w", err)
- }
- logger.Debug("parseGeminiStream: raw output received", "output", string(fullOutput))
-
- inner := stripGeminiFences(string(fullOutput), logger)
- if _, writeErr := w.Write([]byte(inner)); writeErr != nil {
- return 0, fmt.Errorf("writing gemini output: %w", writeErr)
- }
-
- // Walk lines looking for a result event so we can surface errors and cost.
- var (
- cost float64
- errMsg string
- isError bool
- )
- for _, raw := range strings.Split(inner, "\n") {
- line := strings.TrimSpace(raw)
- if line == "" {
- continue
- }
- var evt struct {
- Type string `json:"type"`
- IsError bool `json:"is_error"`
- Result string `json:"result"`
- Cost float64 `json:"total_cost_usd"`
- }
- if err := json.Unmarshal([]byte(line), &evt); err != nil {
- continue
- }
- if evt.Type == "result" {
- if evt.Cost > 0 {
- cost = evt.Cost
- }
- if evt.IsError {
- isError = true
- errMsg = evt.Result
- }
- }
- }
- if isError {
- return cost, fmt.Errorf("gemini reported error: %s", errMsg)
- }
- return cost, nil
-}
-
-// stripGeminiFences removes a surrounding ```json ... ``` markdown block if
-// present, returning the trimmed inner content. If no markdown fence is
-// found, the input is returned verbatim (no whitespace trimming) so callers
-// that expect byte-exact pass-through behavior get it.
-func stripGeminiFences(raw string, logger *slog.Logger) string {
- trimmed := strings.TrimSpace(raw)
- if start := strings.Index(trimmed, "```json"); start != -1 {
- if end := strings.LastIndex(trimmed, "```"); end > start {
- return strings.TrimSpace(trimmed[start+len("```json") : end])
- }
- logger.Warn("malformed gemini markdown block (missing closing fence); using raw output", "len", len(trimmed))
- return trimmed
- }
- return raw
-}
-
-func (r *GeminiRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string {
- // Gemini CLI uses a different command structure: gemini "instructions" [flags]
-
- instructions := t.Agent.Instructions
- if !t.Agent.SkipPlanning {
- instructions = withPlanningPreamble(instructions)
- }
-
- args := []string{
- "-p", instructions,
- "--output-format", "stream-json",
- "--yolo", // auto-approve all tools (equivalent to Claude's bypassPermissions)
- }
-
- // Note: Gemini CLI flags might differ from Claude CLI.
- // Assuming common flags for now, but these may need adjustment.
- if t.Agent.Model != "" {
- args = append(args, "--model", t.Agent.Model)
- }
-
- // Gemini CLI doesn't use --session-id for the first run in the same way,
- // or it might use it differently. For now we assume compatibility.
- if e.SessionID != "" {
- // If it's a resume, it might use different flags.
- if e.ResumeSessionID != "" {
- // This is a placeholder for Gemini's resume logic
- }
- }
-
- return args
-}