diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-07 23:52:26 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-08 04:52:10 +0000 |
| commit | f11f1f8c7d3ce9caca592323def9cc598e5c7392 (patch) | |
| tree | 06f21f27346bdec92a5d077317de3fb179ad5008 /internal | |
| parent | f2d6822db559f680766daf9d66dd3631ed4adcaa (diff) | |
feat(executor): implement GeminiRunner
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/executor/gemini.go | 193 |
1 files changed, 193 insertions, 0 deletions
diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go new file mode 100644 index 0000000..e4bd50e --- /dev/null +++ b/internal/executor/gemini.go @@ -0,0 +1,193 @@ +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 <instructions> 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 +} |
