package executor import ( "context" "fmt" "log/slog" "os" "os/exec" "path/filepath" "strings" "sync" "syscall" "time" "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 a gemini invocation, streaming output to log files. func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { if t.Agent.WorkingDir != "" { if _, err := os.Stat(t.Agent.WorkingDir); err != nil { return fmt.Errorf("working_dir %q: %w", t.Agent.WorkingDir, 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 == "" { e.SessionID = e.ID } questionFile := filepath.Join(logDir, "question.json") args := r.buildArgs(t, e, questionFile) // Gemini CLI doesn't necessarily have the same rate limiting behavior as Claude, // but we'll use a similar execution pattern. err := r.execOnce(ctx, args, t.Agent.WorkingDir, e) if err != nil { return err } // Check whether the agent left a question before exiting. data, readErr := os.ReadFile(questionFile) if readErr == nil { os.Remove(questionFile) // consumed return &BlockedError{QuestionJSON: strings.TrimSpace(string(data)), SessionID: e.SessionID} } return nil } func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir 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_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"), ) 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 costUSD float64 var streamErr error var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() // Reusing parseStream as the JSONL format should be compatible costUSD, streamErr = parseStream(stdoutR, stdoutFile, r.Logger) stdoutR.Close() }() waitErr := cmd.Wait() close(killDone) wg.Wait() e.CostUSD = costUSD if waitErr != nil { if exitErr, ok := waitErr.(*exec.ExitError); ok { e.ExitCode = exitErr.ExitCode() } return fmt.Errorf("gemini exited with error: %w", waitErr) } e.ExitCode = 0 if streamErr != nil { return streamErr } return nil } 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{ instructions, "--output-format", "stream-json", } // 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 }