diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-18 00:52:49 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-18 07:54:48 +0000 |
| commit | 5814e7d6bdec659bb8ca10cc18447a821c59ad4c (patch) | |
| tree | 2106c5a34ae709d4628368a4314f7b1fea076243 /internal/executor/container.go | |
| parent | 0fb4e3e81c20b2e2b58040772b747ec1dd9e09e7 (diff) | |
fix: comprehensive addressing of container execution review feedback
- Fix Critical Bug 1: Only remove workspace on success, preserve on failure/BLOCKED.
- Fix Critical Bug 2: Use correct Claude flag (--resume) and pass instructions via file.
- Fix Critical Bug 3: Actually mount and use the instructions file in the container.
- Address Design Issue 4: Implement Resume/BLOCKED detection and host-side workspace re-use.
- Address Design Issue 5: Consolidate RepositoryURL to Task level and fix API fallback.
- Address Design Issue 6: Make agent images configurable per runner type via CLI flags.
- Address Design Issue 7: Secure API keys via .claudomator-env file and --env-file flag.
- Address Code Quality 8: Add unit tests for ContainerRunner arg construction.
- Address Code Quality 9: Fix indentation regression in app.js.
- Address Code Quality 10: Clean up orphaned Claude/Gemini runner files and move helpers.
- Fix tests: Update server_test.go and executor_test.go to work with new model.
Diffstat (limited to 'internal/executor/container.go')
| -rw-r--r-- | internal/executor/container.go | 172 |
1 files changed, 141 insertions, 31 deletions
diff --git a/internal/executor/container.go b/internal/executor/container.go index e148620..b5979b6 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "syscall" @@ -32,6 +33,7 @@ func (r *ContainerRunner) ExecLogDir(execID string) string { } func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { + var err error repoURL := t.RepositoryURL if repoURL == "" { // Fallback to project_dir if repository_url is not set (legacy support) @@ -51,18 +53,51 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } // 1. Setup workspace on host - workspace, err := os.MkdirTemp("", "claudomator-workspace-*") - if err != nil { - return fmt.Errorf("creating workspace: %w", err) + var workspace string + isResume := false + if e.SandboxDir != "" { + if _, err = os.Stat(e.SandboxDir); err == nil { + workspace = e.SandboxDir + isResume = true + r.Logger.Info("resuming in preserved workspace", "path", workspace) + } } - defer os.RemoveAll(workspace) - // 2. Clone repo into workspace - r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace) - if out, err := exec.CommandContext(ctx, "git", "clone", repoURL, workspace).CombinedOutput(); err != nil { - return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) + if workspace == "" { + workspace, err = os.MkdirTemp("", "claudomator-workspace-*") + if err != nil { + return fmt.Errorf("creating workspace: %w", err) + } } + // Note: workspace is only removed on success. On failure, it's preserved for debugging. + // If the task becomes BLOCKED, it's also preserved for resumption. + success := false + isBlocked := false + defer func() { + if success && !isBlocked { + os.RemoveAll(workspace) + } else { + r.Logger.Warn("preserving workspace", "path", workspace, "success", success, "blocked", isBlocked) + } + }() + + // 2. Clone repo into workspace if not resuming + if !isResume { + r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace) + // git clone requires the target to be empty or non-existent. + // Since we just created workspace as a temp dir, it's empty. + // But git clone wants to CREATE the dir if it's the target, or clone INTO it. + if out, err := exec.CommandContext(ctx, "git", "clone", repoURL, workspace).CombinedOutput(); err != nil { + // If it's a local path and not a repo, we might need to init it (legacy support from ADR-005) + r.Logger.Warn("git clone failed, attempting fallback init", "url", repoURL, "error", err) + if initErr := r.fallbackGitInit(repoURL, workspace); initErr != nil { + return fmt.Errorf("git clone and fallback init failed: %w\n%s", err, string(out)) + } + } + } + e.SandboxDir = workspace + // 3. Prepare logs logDir := r.ExecLogDir(e.ID) if logDir == "" { @@ -88,41 +123,43 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec defer stderrFile.Close() // 4. Run container - // Build docker command - args := []string{ - "run", "--rm", - "-v", workspace + ":/workspace", - "-w", "/workspace", - "-e", "CLAUDOMATOR_API_URL=" + r.APIURL, - "-e", "CLAUDOMATOR_TASK_ID=" + e.TaskID, - "-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir, - "-e", "ANTHROPIC_API_KEY=" + os.Getenv("ANTHROPIC_API_KEY"), - "-e", "GOOGLE_API_KEY=" + os.Getenv("GOOGLE_API_KEY"), + // TODO: Support Resume/BLOCKED by re-attaching to preserved workspace. + + // Write API keys to a temporary env file to avoid exposure in 'ps' or 'docker inspect' + envFile := filepath.Join(workspace, ".claudomator-env") + envContent := fmt.Sprintf("ANTHROPIC_API_KEY=%s\nGOOGLE_API_KEY=%s\n", os.Getenv("ANTHROPIC_API_KEY"), os.Getenv("GOOGLE_API_KEY")) + if err := os.WriteFile(envFile, []byte(envContent), 0600); err != nil { + return fmt.Errorf("writing env file: %w", err) } - // Inject custom instructions as environment variable or via file + // Inject custom instructions via file to avoid CLI length limits instructionsFile := filepath.Join(workspace, ".claudomator-instructions.txt") if err := os.WriteFile(instructionsFile, []byte(t.Agent.Instructions), 0600); err != nil { return fmt.Errorf("writing instructions: %w", err) } - // Command to run inside container: we assume the image has 'claude' or 'gemini' - // and a wrapper script that reads CLAUDOMATOR_TASK_ID etc. - innerCmd := []string{"claude", "-p", t.Agent.Instructions, "--session-id", e.ID, "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"} - if t.Agent.Type == "gemini" { - innerCmd = []string{"gemini", "-p", t.Agent.Instructions} // simplified for now + args := r.buildDockerArgs(workspace, e.TaskID) + innerCmd := r.buildInnerCmd(t, e.ID) + + image = t.Agent.ContainerImage + if image == "" { + image = r.Image + } + if image == "" { + image = "claudomator-agent:latest" } - args = append(args, image) - args = append(args, innerCmd...) + fullArgs := append(args, image) + fullArgs = append(fullArgs, innerCmd...) r.Logger.Info("starting container", "image", image, "taskID", t.ID) - cmd := exec.CommandContext(ctx, "docker", args...) + cmd := exec.CommandContext(ctx, "docker", fullArgs...) cmd.Stderr = stderrFile cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Use os.Pipe for stdout so we can parse it in real-time - stdoutR, stdoutW, err := os.Pipe() + var stdoutR, stdoutW *os.File + stdoutR, stdoutW, err = os.Pipe() if err != nil { return fmt.Errorf("creating stdout pipe: %w", err) } @@ -151,17 +188,41 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec e.CostUSD = costUSD + // Check whether the agent left a question before exiting. + questionFile := filepath.Join(logDir, "question.json") + if data, readErr := os.ReadFile(questionFile); readErr == nil { + os.Remove(questionFile) // consumed + questionJSON := strings.TrimSpace(string(data)) + if isCompletionReport(questionJSON) { + r.Logger.Info("treating question file as completion report", "taskID", e.TaskID) + e.Summary = extractQuestionText(questionJSON) + } else { + isBlocked = true + success = true // We consider BLOCKED as a "success" for workspace preservation + return &BlockedError{ + QuestionJSON: questionJSON, + SessionID: e.ID, // For container runner, we use exec ID as session ID + SandboxDir: workspace, + } + } + } + + // Read agent summary if written. + summaryFile := filepath.Join(logDir, "summary.txt") + if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil { + os.Remove(summaryFile) // consumed + e.Summary = strings.TrimSpace(string(summaryData)) + } + // 5. Post-execution: push changes if successful if waitErr == nil && streamErr == nil { r.Logger.Info("pushing changes back to remote", "url", repoURL) // We assume the sandbox has committed changes (the agent image should enforce this) if out, err := exec.CommandContext(ctx, "git", "-C", workspace, "push", "origin", "HEAD").CombinedOutput(); err != nil { r.Logger.Warn("git push failed", "error", err, "output", string(out)) - // Don't fail the task just because push failed, but record it? - // Actually, user said: "they should only ever commit to their sandbox, and only ever push to an actual remote" - // So push failure is a task failure in this new model. return fmt.Errorf("git push failed: %w\n%s", err, string(out)) } + success = true } if waitErr != nil { @@ -170,3 +231,52 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec return nil } + +func (r *ContainerRunner) buildDockerArgs(workspace, taskID string) []string { + return []string{ + "run", "--rm", + "-v", workspace + ":/workspace", + "-w", "/workspace", + "--env-file", "/workspace/.claudomator-env", + "-e", "CLAUDOMATOR_API_URL=" + r.APIURL, + "-e", "CLAUDOMATOR_TASK_ID=" + taskID, + "-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir, + } +} + +func (r *ContainerRunner) buildInnerCmd(t *task.Task, execID string) []string { + if t.Agent.Type == "gemini" { + return []string{"gemini", "-p", "/workspace/.claudomator-instructions.txt"} + } + // Default to claude + return []string{ + "claude", + "-p", "/workspace/.claudomator-instructions.txt", + "--resume", execID, + "--output-format", "stream-json", + "--verbose", + "--permission-mode", "bypassPermissions", + } +} + +func (r *ContainerRunner) fallbackGitInit(repoURL, workspace string) error { + // Ensure directory exists + if err := os.MkdirAll(workspace, 0755); err != nil { + return err + } + // If it's a local directory but not a repo, init it. + cmds := [][]string{ + gitSafe("-C", workspace, "init"), + gitSafe("-C", workspace, "add", "-A"), + gitSafe("-C", workspace, "commit", "--allow-empty", "-m", "chore: initial commit"), + } + // If it was a local path, maybe we should have copied it? + // git clone handle local paths fine if they are repos. + // This fallback is only if it's NOT a repo. + for _, args := range cmds { + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + return fmt.Errorf("git init failed: %w\n%s", err, out) + } + } + return nil +} |
