From 0fb4e3e81c20b2e2b58040772b747ec1dd9e09e7 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 18 Mar 2026 00:17:50 +0000 Subject: feat: implement containerized repository-based execution model This commit implements the architectural shift from local directory-based sandboxing to containerized execution using canonical repository URLs. Key changes: - Data Model: Added RepositoryURL and ContainerImage to task/agent configs. - Storage: Updated SQLite schema and queries to handle new fields. - Executor: Implemented ContainerRunner using Docker/Podman for isolation. - API/UI: Overhauled task creation to use Repository URLs and Image selection. - Webhook: Updated GitHub webhook to derive Repository URLs automatically. - Docs: Updated ADR-005 with risk feedback and added ADR-006 to document the new containerized model. - Defaults: Updated serve command to use ContainerRunner for all agents. This fixes systemic task failures caused by build dependency and permission issues on the host system. --- images/agent-base/Dockerfile | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 images/agent-base/Dockerfile (limited to 'images/agent-base') diff --git a/images/agent-base/Dockerfile b/images/agent-base/Dockerfile new file mode 100644 index 0000000..71807ae --- /dev/null +++ b/images/agent-base/Dockerfile @@ -0,0 +1,37 @@ +# Claudomator Agent Base Image +FROM ubuntu:22.04 + +# Avoid interactive prompts +ENV DEBIAN_FRONTEND=noninteractive + +# Install core build and dev tools +RUN apt-get update && apt-get install -y \ + git \ + curl \ + make \ + golang \ + nodejs \ + npm \ + sqlite3 \ + jq \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +# Install specific node tools if needed (example: postcss) +RUN npm install -g postcss-cli tailwindcss autoprefixer + +# Setup workspace +WORKDIR /workspace + +# Install Claudomator-aware CLI wrappers (placeholder) +# These will be provided by the Claudomator project in the future. +# For now, we assume 'claude' and 'gemini' binaries are available or mapped. + +# Add a user claudomator-agent +RUN useradd -m claudomator-agent && \ + echo "claudomator-agent ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +USER claudomator-agent + +# Default command +CMD ["/bin/bash"] -- cgit v1.2.3 From a4795d68fc5381f1ff48d043fe7554355e5899fb Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 18 Mar 2026 07:54:27 +0000 Subject: fix: address final container execution issues and cleanup review docs --- docs/reviews/feat-container-execution.md | 130 ------------------------------- images/agent-base/Dockerfile | 22 ++++-- internal/api/webhook.go | 16 ++-- internal/cli/run.go | 32 +++++--- internal/cli/serve.go | 39 ++++++---- internal/config/config.go | 2 + internal/executor/container.go | 98 +++++++++++++++++------ internal/executor/container_test.go | 52 ++++++++++--- internal/executor/helpers.go | 4 +- 9 files changed, 193 insertions(+), 202 deletions(-) delete mode 100644 docs/reviews/feat-container-execution.md (limited to 'images/agent-base') diff --git a/docs/reviews/feat-container-execution.md b/docs/reviews/feat-container-execution.md deleted file mode 100644 index cdcc174..0000000 --- a/docs/reviews/feat-container-execution.md +++ /dev/null @@ -1,130 +0,0 @@ -# Code Review: `feat/container-execution` - -**Branch:** `feat/container-execution` -**Commits reviewed:** -- `e68cc48` feat: implement containerized repository-based execution model -- `f68eb0c` fix: comprehensive addressing of container execution review feedback -- `ad48791` fix: address round 2 review feedback for container execution - ---- - -## Overview - -Replaces `ClaudeRunner`/`GeminiRunner` with a `ContainerRunner`, adds `RepositoryURL` to the task model, and ships ADR-006. The direction is sound. Three rounds of iteration have fixed most of the original issues, but four blocking bugs remain. - ---- - -## Fixed Across All Rounds - -- ✅ Unconditional `defer os.RemoveAll` — replaced with `success`/`isBlocked` defer -- ✅ `--session-id` invalid flag — changed to `--resume` -- ✅ `--resume` on fresh runs — `isResume bool` parameter added to `buildInnerCmd` -- ✅ `-p` passes file path literally — now uses `sh -c "claude -p \"$(cat ...)\""` -- ✅ `streamErr` silently discarded — now returned -- ✅ API keys via `-e` — moved to `--env-file` with host-side path -- ✅ Hardcoded image name — configurable via `cfg.ClaudeImage`/`cfg.GeminiImage` -- ✅ `ClaudeRunner`/`GeminiRunner` orphaned — deleted -- ✅ `RepositoryURL` not checked in `AgentConfig` — fallback added -- ✅ `app.js` indentation regression — fixed -- ✅ Test coverage expanded — `isCompletionReport`, `tailFile`, `gitSafe`, workspace preservation tests added - ---- - -## Blocking Bugs - -### 1. Push failure is silently swallowed — task marked COMPLETED with lost commits - -**File:** `internal/executor/container.go` — `Run` - -```go -if waitErr == nil && streamErr == nil { - success = true // set BEFORE push - if out, err := exec.CommandContext(..., "git", "-C", workspace, "push", "origin", "HEAD").CombinedOutput(); err != nil { - r.Logger.Warn("git push failed or no changes", ...) - // error not returned - } -} -``` - -`success = true` before the push means the workspace is cleaned up whether the push succeeds or not. Push errors are only logged. If the agent commits changes and the push fails (auth, non-fast-forward, network), the task is marked COMPLETED, the workspace is deleted, and the commits are gone. ADR-006 explicitly states: *"If the remote is missing or the push fails, the task is marked FAILED and the host-side workspace is preserved for inspection."* This is the opposite. - -### 2. `--resume` is passed with the wrong session ID - -**File:** `internal/executor/container.go` — `Run`, `buildInnerCmd` - -```go -innerCmd := r.buildInnerCmd(t, e.ID, isResume) -// ... -claudeArgs = append(claudeArgs, "--resume", execID) // execID = e.ID -``` - -`e.ID` is the *current* execution's UUID. `--resume` requires the *previous* Claude session ID, stored in `e.ResumeSessionID`. Passing the wrong ID causes Claude to error with "No conversation found". Should be `e.ResumeSessionID`. - -### 3. `BlockedError.SessionID` is set to the execution UUID, not a Claude session ID - -**File:** `internal/executor/container.go` - -```go -return &BlockedError{ - QuestionJSON: questionJSON, - SessionID: e.ID, // For container runner, we use exec ID as session ID -``` - -The pool stores `BlockedError.SessionID` as the session to `--resume` when the user answers. Using `e.ID` means the resume invocation will fail — Claude has no session with that UUID. The actual Claude session ID must come from the stream output or an agent-written file. `ClaudeRunner` handled this via `e.SessionID` which was set before the run and populated into the stream's session context. - -### 4. `sh -c` quoting breaks on instructions with shell metacharacters - -**File:** `internal/executor/container.go` — `buildInnerCmd` - -```go -claudeArgs := []string{"claude", "-p", "\"$(" + promptCmd + ")\""} -return []string{"sh", "-c", strings.Join(claudeArgs, " ")} -``` - -Produces: `claude -p "$(cat /workspace/.claudomator-instructions.txt)" ...` - -If the instructions file contains `"`, `` ` ``, `$VAR`, or `\`, the shell expansion breaks or executes unintended commands. Task instructions routinely contain code snippets with all of these. A safer pattern uses a shell variable to capture and isolate the expansion: - -```sh -sh -c 'INST=$(cat /workspace/.claudomator-instructions.txt); claude -p "$INST" ...' -``` - -The single-quoted outer string prevents the host shell from interpreting the inner `$INST`. - ---- - -## Non-Blocking Issues - -### 5. `image` variable resolved twice - -**File:** `internal/executor/container.go` — `Run` - -`image` is resolved (ContainerImage → r.Image → default) at the top of `Run`, then the identical three-way resolution runs again after `buildInnerCmd` is called. The first value is immediately overwritten — dead code. - -### 6. `TODO` comment is stale and misplaced - -```go -// TODO: Support Resume/BLOCKED by re-attaching to preserved workspace. -``` - -Resume workspace reuse is already implemented in step 1 (`e.SandboxDir` check). The BLOCKED path is handled after `cmd.Wait()`. The comment is inaccurate; the actual unresolved issue is the session ID problem (bug #3 above). - -### 7. Test coverage still missing for the most critical paths - -Round 3 restored `isCompletionReport`, `tailFile`, and `gitSafe` tests. Still missing: goroutine leak detection, rate-limit retry behavior, and session ID propagation across a BLOCKED → resume cycle. These are the tests most likely to catch bugs #2 and #3 in CI. - -### 8. ADR-006 claims "Supersedes ADR-005" but ADR-005 Status was not updated - -ADR-005 should add a "Superseded by ADR-006" line to its Status section. - ---- - -## Verdict - -**Not mergeable.** Bugs 1–4 are all functional failures: - -- Bug 1: silently discarded push failures → lost commits, false COMPLETED status -- Bugs 2 & 3: wrong session IDs → every resume fails with "No conversation found" -- Bug 4: shell quoting → any task with code in its instructions silently misbehaves - -Bug 1 is a regression introduced in round 3 (previously push failures correctly failed the task). Bugs 2–3 have been present since the first commit and were not caught by the new tests because no test exercises the BLOCKED → resume flow end-to-end. diff --git a/images/agent-base/Dockerfile b/images/agent-base/Dockerfile index 71807ae..6fb253c 100644 --- a/images/agent-base/Dockerfile +++ b/images/agent-base/Dockerfile @@ -1,5 +1,5 @@ # Claudomator Agent Base Image -FROM ubuntu:22.04 +FROM ubuntu:24.04 # Avoid interactive prompts ENV DEBIAN_FRONTEND=noninteractive @@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y \ git \ curl \ make \ - golang \ + wget \ nodejs \ npm \ sqlite3 \ @@ -17,20 +17,28 @@ RUN apt-get update && apt-get install -y \ sudo \ && rm -rf /var/lib/apt/lists/* -# Install specific node tools if needed (example: postcss) +# Install Go 1.22+ +RUN wget https://go.dev/dl/go1.22.1.linux-amd64.tar.gz && \ + tar -C /usr/local -xzf go1.22.1.linux-amd64.tar.gz && \ + rm go1.22.1.linux-amd64.tar.gz +ENV PATH=$PATH:/usr/local/go/bin + +# Install Claude CLI +RUN npm install -g @anthropic-ai/claude-code + +# Install specific node tools RUN npm install -g postcss-cli tailwindcss autoprefixer # Setup workspace WORKDIR /workspace -# Install Claudomator-aware CLI wrappers (placeholder) -# These will be provided by the Claudomator project in the future. -# For now, we assume 'claude' and 'gemini' binaries are available or mapped. - # Add a user claudomator-agent RUN useradd -m claudomator-agent && \ echo "claudomator-agent ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers +# Ensure /usr/local/bin is writable for npm or use a different path +# @anthropic-ai/claude-code might need some extra setup or just work + USER claudomator-agent # Default command diff --git a/internal/api/webhook.go b/internal/api/webhook.go index a28b43f..141224f 100644 --- a/internal/api/webhook.go +++ b/internal/api/webhook.go @@ -210,16 +210,16 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, MaxBudgetUSD: 3.0, AllowedTools: []string{"Read", "Edit", "Bash", "Glob", "Grep"}, }, - Priority: task.PriorityNormal, - Tags: []string{"ci", "auto"}, - DependsOn: []string{}, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, - State: task.StatePending, - CreatedAt: now, - UpdatedAt: now, + Priority: task.PriorityNormal, + Tags: []string{"ci", "auto"}, + DependsOn: []string{}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + RepositoryURL: fmt.Sprintf("https://github.com/%s.git", fullName), } if project != nil { - t.RepositoryURL = fmt.Sprintf("https://github.com/%s.git", fullName) t.Project = project.Name } diff --git a/internal/cli/run.go b/internal/cli/run.go index 9663bc5..cfac893 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -72,22 +72,34 @@ func runTasks(file string, parallel int, dryRun bool) error { logger := newLogger(verbose) + apiURL := "http://localhost" + cfg.ServerAddr + if len(cfg.ServerAddr) > 0 && cfg.ServerAddr[0] != ':' { + apiURL = "http://" + cfg.ServerAddr + } + runners := map[string]executor.Runner{ "claude": &executor.ContainerRunner{ - Image: cfg.ClaudeImage, - Logger: logger, - LogDir: cfg.LogDir, - APIURL: "http://" + cfg.ServerAddr, - DropsDir: cfg.DropsDir, + Image: cfg.ClaudeImage, + Logger: logger, + LogDir: cfg.LogDir, + APIURL: apiURL, + DropsDir: cfg.DropsDir, + SSHAuthSock: cfg.SSHAuthSock, + ClaudeBinary: cfg.ClaudeBinaryPath, + GeminiBinary: cfg.GeminiBinaryPath, }, "gemini": &executor.ContainerRunner{ - Image: cfg.GeminiImage, - Logger: logger, - LogDir: cfg.LogDir, - APIURL: "http://" + cfg.ServerAddr, - DropsDir: cfg.DropsDir, + Image: cfg.GeminiImage, + Logger: logger, + LogDir: cfg.LogDir, + APIURL: apiURL, + DropsDir: cfg.DropsDir, + SSHAuthSock: cfg.SSHAuthSock, + ClaudeBinary: cfg.ClaudeBinaryPath, + GeminiBinary: cfg.GeminiBinaryPath, }, } + pool := executor.NewPool(parallel, runners, store, logger) if cfg.GeminiBinaryPath != "" { pool.Classifier = &executor.Classifier{GeminiBinaryPath: cfg.GeminiBinaryPath} diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 33715ee..2ee020d 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -77,25 +77,34 @@ func serve(addr string) error { runners := map[string]executor.Runner{ "claude": &executor.ContainerRunner{ - Image: cfg.ClaudeImage, - Logger: logger, - LogDir: cfg.LogDir, - APIURL: apiURL, - DropsDir: cfg.DropsDir, + Image: cfg.ClaudeImage, + Logger: logger, + LogDir: cfg.LogDir, + APIURL: apiURL, + DropsDir: cfg.DropsDir, + SSHAuthSock: cfg.SSHAuthSock, + ClaudeBinary: cfg.ClaudeBinaryPath, + GeminiBinary: cfg.GeminiBinaryPath, }, "gemini": &executor.ContainerRunner{ - Image: cfg.GeminiImage, - Logger: logger, - LogDir: cfg.LogDir, - APIURL: apiURL, - DropsDir: cfg.DropsDir, + Image: cfg.GeminiImage, + Logger: logger, + LogDir: cfg.LogDir, + APIURL: apiURL, + DropsDir: cfg.DropsDir, + SSHAuthSock: cfg.SSHAuthSock, + ClaudeBinary: cfg.ClaudeBinaryPath, + GeminiBinary: cfg.GeminiBinaryPath, }, "container": &executor.ContainerRunner{ - Image: "claudomator-agent:latest", - Logger: logger, - LogDir: cfg.LogDir, - APIURL: apiURL, - DropsDir: cfg.DropsDir, + Image: "claudomator-agent:latest", + Logger: logger, + LogDir: cfg.LogDir, + APIURL: apiURL, + DropsDir: cfg.DropsDir, + SSHAuthSock: cfg.SSHAuthSock, + ClaudeBinary: cfg.ClaudeBinaryPath, + GeminiBinary: cfg.GeminiBinaryPath, }, } diff --git a/internal/config/config.go b/internal/config/config.go index 6e163c4..fa76b1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { DBPath string `toml:"-"` LogDir string `toml:"-"` DropsDir string `toml:"-"` + SSHAuthSock string `toml:"ssh_auth_sock"` ClaudeBinaryPath string `toml:"claude_binary_path"` GeminiBinaryPath string `toml:"gemini_binary_path"` ClaudeImage string `toml:"claude_image"` @@ -50,6 +51,7 @@ func Default() (*Config, error) { DBPath: filepath.Join(dataDir, "claudomator.db"), LogDir: filepath.Join(dataDir, "executions"), DropsDir: filepath.Join(dataDir, "drops"), + SSHAuthSock: os.Getenv("SSH_AUTH_SOCK"), ClaudeBinaryPath: "claude", GeminiBinaryPath: "gemini", ClaudeImage: "claudomator-agent:latest", diff --git a/internal/executor/container.go b/internal/executor/container.go index d21aea3..45758d2 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -17,12 +17,23 @@ import ( // ContainerRunner executes an agent inside a container. type ContainerRunner struct { - Image string // default image if not specified in task - Logger *slog.Logger - LogDir string - APIURL string - DropsDir string - SSHAuthSock string // optional path to host SSH agent + Image string // default image if not specified in task + Logger *slog.Logger + LogDir string + APIURL string + DropsDir string + SSHAuthSock string // optional path to host SSH agent + ClaudeBinary string // optional path to claude binary in container + GeminiBinary string // optional path to gemini binary in container + // Command allows mocking exec.CommandContext for tests. + Command func(ctx context.Context, name string, arg ...string) *exec.Cmd +} + +func (r *ContainerRunner) command(ctx context.Context, name string, arg ...string) *exec.Cmd { + if r.Command != nil { + return r.Command(ctx, name, arg...) + } + return exec.CommandContext(ctx, name, arg...) } func (r *ContainerRunner) ExecLogDir(execID string) string { @@ -88,7 +99,11 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec // 2. Clone repo into workspace if not resuming if !isResume { r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace) - if out, err := exec.CommandContext(ctx, "git", "clone", repoURL, workspace).CombinedOutput(); err != nil { + if out, err := r.command(ctx, "git", "clone", repoURL, workspace).CombinedOutput(); err != nil { + // If it looks like a remote URL, fail fast. + if strings.HasPrefix(repoURL, "http") || strings.HasPrefix(repoURL, "git@") || strings.HasPrefix(repoURL, "ssh://") { + return fmt.Errorf("git clone failed for remote repository: %w\n%s", err, string(out)) + } 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)) @@ -143,7 +158,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec fullArgs = append(fullArgs, innerCmd...) r.Logger.Info("starting container", "image", image, "taskID", t.ID) - cmd := exec.CommandContext(ctx, "docker", fullArgs...) + cmd := r.command(ctx, "docker", fullArgs...) cmd.Stderr = stderrFile cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} @@ -162,6 +177,18 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } stdoutW.Close() + // Watch for context cancellation to kill the process group (Issue 1) + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + r.Logger.Info("killing container process group due to context cancellation", "taskID", t.ID) + syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + case <-done: + } + }() + // Stream stdout to the log file and parse cost/errors. var costUSD float64 var sessionID string @@ -193,6 +220,9 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } else { isBlocked = true success = true // We consider BLOCKED as a "success" for workspace preservation + if e.SessionID == "" { + r.Logger.Warn("missing session ID; resume will start fresh", "taskID", e.TaskID) + } return &BlockedError{ QuestionJSON: questionJSON, SessionID: e.SessionID, @@ -210,14 +240,24 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec // 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 or no changes", "error", err, "output", string(out)) - // Only set success = true if we consider this "good enough". - // Review says: "If the remote is missing or the push fails, the task is marked FAILED and the host-side workspace is preserved" - // So we MUST return error here. - return fmt.Errorf("git push failed: %w\n%s", err, string(out)) + // Check if there are any commits to push (Issue 10) + // We use rev-list to see if HEAD is ahead of origin/HEAD. + // If origin/HEAD doesn't exist (e.g. fresh init), we just attempt to push. + hasCommits := true + if out, err := r.command(ctx, "git", "-C", workspace, "rev-list", "origin/HEAD..HEAD").CombinedOutput(); err == nil { + if len(strings.TrimSpace(string(out))) == 0 { + hasCommits = false + } + } + + if hasCommits { + r.Logger.Info("pushing changes back to remote", "url", repoURL) + if out, err := r.command(ctx, "git", "-C", workspace, "push", "origin", "HEAD").CombinedOutput(); err != nil { + r.Logger.Warn("git push failed", "error", err, "output", string(out)) + return fmt.Errorf("git push failed: %w\n%s", err, string(out)) + } + } else { + r.Logger.Info("no new commits to push", "taskID", t.ID) } success = true } @@ -235,7 +275,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec func (r *ContainerRunner) buildDockerArgs(workspace, taskID string) []string { // --env-file takes a HOST path. hostEnvFile := filepath.Join(workspace, ".claudomator-env") - return []string{ + args := []string{ "run", "--rm", "-v", workspace + ":/workspace", "-w", "/workspace", @@ -244,28 +284,42 @@ func (r *ContainerRunner) buildDockerArgs(workspace, taskID string) []string { "-e", "CLAUDOMATOR_TASK_ID=" + taskID, "-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir, } + if r.SSHAuthSock != "" { + args = append(args, "-v", r.SSHAuthSock+":/tmp/ssh-auth.sock", "-e", "SSH_AUTH_SOCK=/tmp/ssh-auth.sock") + } + return args } func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isResume bool) []string { // Claude CLI uses -p for prompt text. To pass a file, we use a shell to cat it. // We use a shell variable to capture the expansion to avoid quoting issues with instructions contents. // The outer single quotes around the sh -c argument prevent host-side expansion. - + + claudeBin := r.ClaudeBinary + if claudeBin == "" { + claudeBin = "claude" + } + geminiBin := r.GeminiBinary + if geminiBin == "" { + geminiBin = "gemini" + } + if t.Agent.Type == "gemini" { - return []string{"sh", "-c", "INST=$(cat /workspace/.claudomator-instructions.txt); gemini -p \"$INST\""} + return []string{"sh", "-c", fmt.Sprintf("INST=$(cat /workspace/.claudomator-instructions.txt); %s -p \"$INST\"", geminiBin)} } // Claude var claudeCmd strings.Builder - claudeCmd.WriteString("INST=$(cat /workspace/.claudomator-instructions.txt); claude -p \"$INST\"") + claudeCmd.WriteString(fmt.Sprintf("INST=$(cat /workspace/.claudomator-instructions.txt); %s -p \"$INST\"", claudeBin)) if isResume && e.ResumeSessionID != "" { claudeCmd.WriteString(fmt.Sprintf(" --resume %s", e.ResumeSessionID)) } claudeCmd.WriteString(" --output-format stream-json --verbose --permission-mode bypassPermissions") - + return []string{"sh", "-c", claudeCmd.String()} } + func (r *ContainerRunner) fallbackGitInit(repoURL, workspace string) error { // Ensure directory exists if err := os.MkdirAll(workspace, 0755); err != nil { @@ -281,7 +335,7 @@ func (r *ContainerRunner) fallbackGitInit(repoURL, workspace string) error { // 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 { + if out, err := r.command(context.Background(), "git", args...).CombinedOutput(); err != nil { return fmt.Errorf("git init failed: %w\n%s", err, out) } } diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index 0e36def..d4d591e 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "os" + "os/exec" "strings" "testing" @@ -15,14 +16,15 @@ import ( func TestContainerRunner_BuildDockerArgs(t *testing.T) { runner := &ContainerRunner{ - APIURL: "http://localhost:8484", - DropsDir: "/data/drops", + APIURL: "http://localhost:8484", + DropsDir: "/data/drops", + SSHAuthSock: "/tmp/ssh.sock", } workspace := "/tmp/ws" taskID := "task-123" args := runner.buildDockerArgs(workspace, taskID) - + expected := []string{ "run", "--rm", "-v", "/tmp/ws:/workspace", @@ -31,11 +33,12 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) { "-e", "CLAUDOMATOR_API_URL=http://localhost:8484", "-e", "CLAUDOMATOR_TASK_ID=task-123", "-e", "CLAUDOMATOR_DROP_DIR=/data/drops", + "-v", "/tmp/ssh.sock:/tmp/ssh-auth.sock", + "-e", "SSH_AUTH_SOCK=/tmp/ssh-auth.sock", } - if len(args) != len(expected) { - t.Fatalf("expected %d args, got %d", len(expected), len(args)) + t.Fatalf("expected %d args, got %d. Got: %v", len(expected), len(args), args) } for i, v := range args { if v != expected[i] { @@ -76,12 +79,31 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "gemini"}} exec := &storage.Execution{} cmd := runner.buildInnerCmd(tk, exec, false) - + cmdStr := strings.Join(cmd, " ") if !strings.Contains(cmdStr, "gemini -p \"$INST\"") { t.Errorf("expected gemini command with safer quoting, got %q", cmdStr) } }) + + t.Run("custom-binaries", func(t *testing.T) { + runnerCustom := &ContainerRunner{ + ClaudeBinary: "/usr/bin/claude-v2", + GeminiBinary: "/usr/local/bin/gemini-pro", + } + + tkClaude := &task.Task{Agent: task.AgentConfig{Type: "claude"}} + cmdClaude := runnerCustom.buildInnerCmd(tkClaude, &storage.Execution{}, false) + if !strings.Contains(strings.Join(cmdClaude, " "), "/usr/bin/claude-v2 -p") { + t.Errorf("expected custom claude binary, got %q", cmdClaude) + } + + tkGemini := &task.Task{Agent: task.AgentConfig{Type: "gemini"}} + cmdGemini := runnerCustom.buildInnerCmd(tkGemini, &storage.Execution{}, false) + if !strings.Contains(strings.Join(cmdGemini, " "), "/usr/local/bin/gemini-pro -p") { + t.Errorf("expected custom gemini binary, got %q", cmdGemini) + } + }) } func TestContainerRunner_Run_PreservesWorkspaceOnFailure(t *testing.T) { @@ -89,19 +111,31 @@ func TestContainerRunner_Run_PreservesWorkspaceOnFailure(t *testing.T) { runner := &ContainerRunner{ Logger: logger, Image: "busybox", + Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { + // Mock docker run to exit 1 + if name == "docker" { + return exec.Command("sh", "-c", "exit 1") + } + // Mock git clone to succeed and create the directory + if name == "git" && len(arg) > 0 && arg[0] == "clone" { + dir := arg[len(arg)-1] + os.MkdirAll(dir, 0755) + return exec.Command("true") + } + return exec.Command("true") + }, } - // Use an invalid repo URL to trigger failure. tk := &task.Task{ ID: "test-task", - RepositoryURL: "/nonexistent/repo", + RepositoryURL: "https://github.com/example/repo.git", Agent: task.AgentConfig{Type: "claude"}, } exec := &storage.Execution{ID: "test-exec", TaskID: "test-task"} err := runner.Run(context.Background(), tk, exec) if err == nil { - t.Fatal("expected error due to invalid repo") + t.Fatal("expected error due to mocked docker failure") } // Verify SandboxDir was set and directory exists. diff --git a/internal/executor/helpers.go b/internal/executor/helpers.go index 36cd050..9e4530b 100644 --- a/internal/executor/helpers.go +++ b/internal/executor/helpers.go @@ -33,6 +33,7 @@ func parseStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, string var sessionID string var streamErr error +Loop: for scanner.Scan() { line := scanner.Bytes() var msg map[string]interface{} @@ -54,7 +55,7 @@ func parseStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, string if status == "rejected" { streamErr = fmt.Errorf("claude rate limit reached (rejected): %v", msg) // Immediately break since we can't continue anyway - break + break Loop } } case "assistant": @@ -91,6 +92,7 @@ func parseStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, string return totalCost, sessionID, streamErr } + // permissionDenialError inspects a "user" stream message for tool_result entries // that were denied due to missing permissions. Returns an error if found. func permissionDenialError(msg map[string]interface{}) error { -- cgit v1.2.3 From 7df4f06ae0e3ae80bd967bf53cbec36e58b4a3bd Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 18 Mar 2026 23:56:20 +0000 Subject: feat: containerized execution with agent tooling and deployment fixes - ContainerRunner replaces ClaudeRunner/GeminiRunner; all agent types run in Docker containers via claudomator-agent:latest - Writable agentHome staging dir (/home/agent) satisfies home-dir requirements for both claude and gemini CLIs without exposing host creds - Copy .credentials.json and .claude.json into staging dir at run time; GEMINI_API_KEY passed via env file - Fix git clone: remove MkdirTemp-created dir before cloning (git rejects pre-existing dirs even when empty) - Replace localhost with host.docker.internal in APIURL so container can reach host API; add --add-host=host.docker.internal:host-gateway - Run container as --user=$(uid):$(gid) so host-owned workspace files are readable; chmod workspace 0755 and instructions file 0644 after clone - Pre-create .gemini/ in staging dir to avoid atomic-rename ENOENT on first gemini-cli run - Add ct CLI tool to container image: pre-built Bash wrapper for Claudomator API (ct task submit/create/run/wait/status/list) - Document ct tool in CLAUDE.md agent instructions section - Add drain-failed-tasks script: retries failed tasks on a 5-minute interval - Update Dockerfile: Node 22 via NodeSource, Go 1.24, gemini-cli, git safe.directory=*, default ~/.claude.json Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 52 ++++++++- images/agent-base/Dockerfile | 43 +++++--- images/agent-base/tools/ct | 210 ++++++++++++++++++++++++++++++++++++ internal/cli/serve.go | 50 ++++----- internal/executor/container.go | 79 ++++++++++---- internal/executor/container_test.go | 9 +- scripts/drain-failed-tasks | 22 ++++ 7 files changed, 398 insertions(+), 67 deletions(-) create mode 100644 images/agent-base/tools/ct create mode 100644 scripts/drain-failed-tasks (limited to 'images/agent-base') diff --git a/CLAUDE.md b/CLAUDE.md index 2cb37a8..d804a96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,14 +53,14 @@ Config defaults to `~/.claudomator/config.toml`. Data is stored in `~/.claudomat ## Architecture -**Pipeline:** CLI/API → `executor.Pool` → `executor.ClaudeRunner` → `claude -p` subprocess → SQLite + log files +**Pipeline:** CLI/API → `executor.Pool` → `executor.ContainerRunner` → Docker container → SQLite + log files ### Packages | Package | Role | |---|---| | `internal/task` | `Task` struct, YAML parsing, state machine, validation | -| `internal/executor` | `Pool` (bounded goroutine pool) + `ClaudeRunner` (subprocess manager) | +| `internal/executor` | `Pool` (bounded goroutine pool) + `ContainerRunner` (Docker-based executor) | | `internal/storage` | SQLite wrapper; stores tasks and execution records | | `internal/api` | HTTP server (REST + WebSocket via `internal/api.Hub`) | | `internal/reporter` | Formats and emits execution results | @@ -72,9 +72,9 @@ Config defaults to `~/.claudomator/config.toml`. Data is stored in `~/.claudomat **Task execution:** 1. Task created via `POST /api/tasks` or YAML file (`task.ParseFile`) 2. `POST /api/tasks/{id}/run` → `executor.Pool.Submit()` → goroutine in pool -3. `ClaudeRunner.Run()` invokes `claude -p --output-format stream-json` -4. stdout streamed to `~/.claudomator/executions//stdout.log`; cost parsed from stream-json -5. Execution result written to SQLite; broadcast via WebSocket to connected clients +3. `ContainerRunner.Run()` clones `repository_url`, runs `docker run claudomator-agent:latest` +4. Agent runs `claude -p` inside the container; stdout streamed to `executions//stdout.log` +5. On success, runner pushes commits back to the remote; execution result written to SQLite + WebSocket broadcast **State machine** (`task.ValidTransition`): `PENDING` → `QUEUED` → `RUNNING` → `COMPLETED | FAILED | TIMED_OUT | CANCELLED | BUDGET_EXCEEDED` @@ -166,6 +166,48 @@ A task is created for: Tasks are tagged `["ci", "auto"]`, capped at $3 USD, and use tools: Read, Edit, Bash, Glob, Grep. +## Agent Tooling (`ct` CLI) + +Agents running inside containers have access to `ct`, a pre-built CLI for interacting with the Claudomator API. It is installed at `/usr/local/bin/ct` in the container image. **Use `ct` to create and manage subtasks — do not attempt raw `curl` API calls.** + +### Environment (injected automatically) + +| Variable | Purpose | +|---|---| +| `CLAUDOMATOR_API_URL` | Base URL of the Claudomator API (e.g. `http://host.docker.internal:8484`) | +| `CLAUDOMATOR_TASK_ID` | ID of the currently-running task; used as the default `parent_task_id` for new subtasks | + +### Commands + +```bash +# Create a subtask and immediately queue it (returns task ID) +ct task submit --name "Fix tests" --instructions "Run tests and fix any failures." [--model sonnet] [--budget 3.0] + +# Create, queue, and wait for completion (exits 0=COMPLETED, 1=FAILED, 2=BLOCKED) +ct task submit --name "Fix tests" --instructions "..." --wait + +# Read instructions from a file instead of inline +ct task submit --name "Fix tests" --file /workspace/subtask-instructions.txt --wait + +# Lower-level: create only (returns task ID), then run separately +TASK_ID=$(ct task create --name "..." --instructions "...") +ct task run "$TASK_ID" +ct task wait "$TASK_ID" --timeout 600 + +# Check status of any task +ct task status + +# List recent tasks +ct task list +``` + +### Notes + +- Default model is `sonnet`; default budget is `$3.00 USD`. Override with `--model` / `--budget`. +- `ct task wait` polls every 5 seconds and exits with the task's terminal state on stdout. +- Subtasks inherit the current task as their parent automatically (via `$CLAUDOMATOR_TASK_ID`). +- Override parent with `--parent ` if needed. + ## ADRs See `docs/adr/001-language-and-architecture.md` for the Go + SQLite + WebSocket rationale. diff --git a/images/agent-base/Dockerfile b/images/agent-base/Dockerfile index 6fb253c..0e8057c 100644 --- a/images/agent-base/Dockerfile +++ b/images/agent-base/Dockerfile @@ -1,45 +1,58 @@ # Claudomator Agent Base Image FROM ubuntu:24.04 -# Avoid interactive prompts ENV DEBIAN_FRONTEND=noninteractive -# Install core build and dev tools +# Base system tools RUN apt-get update && apt-get install -y \ git \ curl \ make \ wget \ - nodejs \ - npm \ sqlite3 \ jq \ sudo \ + ca-certificates \ && rm -rf /var/lib/apt/lists/* -# Install Go 1.22+ -RUN wget https://go.dev/dl/go1.22.1.linux-amd64.tar.gz && \ - tar -C /usr/local -xzf go1.22.1.linux-amd64.tar.gz && \ - rm go1.22.1.linux-amd64.tar.gz +# Node.js 22 via NodeSource +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Go 1.24 +RUN wget -q https://go.dev/dl/go1.24.1.linux-amd64.tar.gz && \ + tar -C /usr/local -xzf go1.24.1.linux-amd64.tar.gz && \ + rm go1.24.1.linux-amd64.tar.gz ENV PATH=$PATH:/usr/local/go/bin -# Install Claude CLI +# Claude Code CLI RUN npm install -g @anthropic-ai/claude-code -# Install specific node tools +# Gemini CLI +RUN npm install -g @google/gemini-cli + +# CSS build tools (for claudomator itself) RUN npm install -g postcss-cli tailwindcss autoprefixer +# Git: allow operations on any directory (agents clone into /workspace/*) +RUN git config --system safe.directory '*' + +# Claudomator agent CLI tools (ct) +COPY tools/ct /usr/local/bin/ct +RUN chmod +x /usr/local/bin/ct + # Setup workspace WORKDIR /workspace -# Add a user claudomator-agent +# Agent user with passwordless sudo RUN useradd -m claudomator-agent && \ echo "claudomator-agent ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers -# Ensure /usr/local/bin is writable for npm or use a different path -# @anthropic-ai/claude-code might need some extra setup or just work - USER claudomator-agent -# Default command +# Create a default empty config to satisfy the CLI if no mount is provided +RUN mkdir -p /home/claudomator-agent/.claude && \ + echo '{}' > /home/claudomator-agent/.claude.json + CMD ["/bin/bash"] diff --git a/images/agent-base/tools/ct b/images/agent-base/tools/ct new file mode 100644 index 0000000..46d9613 --- /dev/null +++ b/images/agent-base/tools/ct @@ -0,0 +1,210 @@ +#!/bin/bash +# ct - Claudomator CLI for agents running inside containers +# +# Usage: +# ct task create --name "..." --instructions "..." # create subtask (parent auto-set) +# ct task run # queue a task for execution +# ct task wait [--timeout 300] # poll until done, print status +# ct task status # print current state +# ct task list # list recent tasks +# +# Environment (injected by ContainerRunner): +# CLAUDOMATOR_API_URL base URL of the Claudomator API +# CLAUDOMATOR_TASK_ID ID of the currently running task (used as default parent) + +set -euo pipefail + +API="${CLAUDOMATOR_API_URL:-http://host.docker.internal:8484}" +PARENT="${CLAUDOMATOR_TASK_ID:-}" + +_api() { + local method="$1"; shift + local path="$1"; shift + curl -sf -X "$method" "${API}${path}" \ + -H "Content-Type: application/json" \ + "$@" +} + +_require() { + if ! command -v "$1" &>/dev/null; then + echo "ct: required tool '$1' not found" >&2 + exit 1 + fi +} + +_require curl +_require jq + +cmd_task_create() { + local name="" instructions="" instructions_file="" model="" budget="" parent="$PARENT" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --instructions) instructions="$2"; shift 2 ;; + --file) instructions_file="$2"; shift 2 ;; + --model) model="$2"; shift 2 ;; + --budget) budget="$2"; shift 2 ;; + --parent) parent="$2"; shift 2 ;; + *) echo "ct task create: unknown flag $1" >&2; exit 1 ;; + esac + done + + if [[ -z "$name" ]]; then + echo "ct task create: --name is required" >&2; exit 1 + fi + + if [[ -n "$instructions_file" ]]; then + instructions=$(cat "$instructions_file") + fi + + if [[ -z "$instructions" ]]; then + echo "ct task create: --instructions or --file is required" >&2; exit 1 + fi + + local payload + payload=$(jq -n \ + --arg name "$name" \ + --arg instructions "$instructions" \ + --arg parent "$parent" \ + --arg model "${model:-sonnet}" \ + --argjson budget "${budget:-3.0}" \ + '{ + name: $name, + parent_task_id: $parent, + agent: { + type: "claude", + model: $model, + instructions: $instructions, + max_budget_usd: $budget + } + }') + + local response + response=$(_api POST /api/tasks -d "$payload") + local task_id + task_id=$(echo "$response" | jq -r '.id // empty') + + if [[ -z "$task_id" ]]; then + echo "ct task create: API error: $(echo "$response" | jq -r '.error // .')" >&2 + exit 1 + fi + + echo "$task_id" +} + +cmd_task_run() { + local task_id="${1:-}" + if [[ -z "$task_id" ]]; then + echo "ct task run: task-id required" >&2; exit 1 + fi + + local response + response=$(_api POST "/api/tasks/${task_id}/run") + echo "$response" | jq -r '.message // .error // .' +} + +cmd_task_wait() { + local task_id="${1:-}" + local timeout=300 + shift || true + + while [[ $# -gt 0 ]]; do + case "$1" in + --timeout) timeout="$2"; shift 2 ;; + *) echo "ct task wait: unknown flag $1" >&2; exit 1 ;; + esac + done + + if [[ -z "$task_id" ]]; then + echo "ct task wait: task-id required" >&2; exit 1 + fi + + local deadline=$(( $(date +%s) + timeout )) + local interval=5 + + while true; do + local response + response=$(_api GET "/api/tasks/${task_id}" 2>/dev/null) || true + + local state + state=$(echo "$response" | jq -r '.state // "UNKNOWN"') + + case "$state" in + COMPLETED|FAILED|TIMED_OUT|CANCELLED|BUDGET_EXCEEDED) + echo "$state" + [[ "$state" == "COMPLETED" ]] && exit 0 || exit 1 + ;; + BLOCKED) + echo "BLOCKED" + exit 2 + ;; + esac + + if [[ $(date +%s) -ge $deadline ]]; then + echo "ct task wait: timed out after ${timeout}s (state: $state)" >&2 + exit 1 + fi + + sleep "$interval" + done +} + +cmd_task_status() { + local task_id="${1:-}" + if [[ -z "$task_id" ]]; then + echo "ct task status: task-id required" >&2; exit 1 + fi + _api GET "/api/tasks/${task_id}" | jq -r '.state' +} + +cmd_task_list() { + _api GET "/api/tasks" | jq -r '.[] | "\(.state)\t\(.id)\t\(.name)"' | sort +} + +# create-and-run shorthand: create a subtask and immediately queue it, then optionally wait +cmd_task_submit() { + local wait=false + local args=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --wait) wait=true; shift ;; + *) args+=("$1"); shift ;; + esac + done + + local task_id + task_id=$(cmd_task_create "${args[@]}") + cmd_task_run "$task_id" >/dev/null + echo "$task_id" + + if $wait; then + cmd_task_wait "$task_id" + fi +} + +# Dispatch +if [[ $# -lt 2 ]]; then + echo "Usage: ct [args...]" + echo " ct task create --name NAME --instructions TEXT [--file FILE] [--model MODEL] [--budget N]" + echo " ct task submit --name NAME --instructions TEXT [--wait]" + echo " ct task run " + echo " ct task wait [--timeout 300]" + echo " ct task status " + echo " ct task list" + exit 1 +fi + +resource="$1"; shift +command="$1"; shift + +case "${resource}/${command}" in + task/create) cmd_task_create "$@" ;; + task/run) cmd_task_run "$@" ;; + task/wait) cmd_task_wait "$@" ;; + task/status) cmd_task_status "$@" ;; + task/list) cmd_task_list ;; + task/submit) cmd_task_submit "$@" ;; + *) echo "ct: unknown command: ${resource} ${command}" >&2; exit 1 ;; +esac diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 2ee020d..98e7524 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -75,36 +75,38 @@ func serve(addr string) error { apiURL = "http://" + addr } + // Resolve the claude config dir from HOME so the container can mount credentials. + claudeConfigDir := filepath.Join(os.Getenv("HOME"), ".claude") + runners := map[string]executor.Runner{ + // ContainerRunner: binaries are resolved via PATH inside the container image, + // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside). "claude": &executor.ContainerRunner{ - Image: cfg.ClaudeImage, - Logger: logger, - LogDir: cfg.LogDir, - APIURL: apiURL, - DropsDir: cfg.DropsDir, - SSHAuthSock: cfg.SSHAuthSock, - ClaudeBinary: cfg.ClaudeBinaryPath, - GeminiBinary: cfg.GeminiBinaryPath, + Image: cfg.ClaudeImage, + Logger: logger, + LogDir: cfg.LogDir, + APIURL: apiURL, + DropsDir: cfg.DropsDir, + SSHAuthSock: cfg.SSHAuthSock, + ClaudeConfigDir: claudeConfigDir, }, "gemini": &executor.ContainerRunner{ - Image: cfg.GeminiImage, - Logger: logger, - LogDir: cfg.LogDir, - APIURL: apiURL, - DropsDir: cfg.DropsDir, - SSHAuthSock: cfg.SSHAuthSock, - ClaudeBinary: cfg.ClaudeBinaryPath, - GeminiBinary: cfg.GeminiBinaryPath, + Image: cfg.GeminiImage, + Logger: logger, + LogDir: cfg.LogDir, + APIURL: apiURL, + DropsDir: cfg.DropsDir, + SSHAuthSock: cfg.SSHAuthSock, + ClaudeConfigDir: claudeConfigDir, }, "container": &executor.ContainerRunner{ - Image: "claudomator-agent:latest", - Logger: logger, - LogDir: cfg.LogDir, - APIURL: apiURL, - DropsDir: cfg.DropsDir, - SSHAuthSock: cfg.SSHAuthSock, - ClaudeBinary: cfg.ClaudeBinaryPath, - GeminiBinary: cfg.GeminiBinaryPath, + Image: "claudomator-agent:latest", + Logger: logger, + LogDir: cfg.LogDir, + APIURL: apiURL, + DropsDir: cfg.DropsDir, + SSHAuthSock: cfg.SSHAuthSock, + ClaudeConfigDir: claudeConfigDir, }, } diff --git a/internal/executor/container.go b/internal/executor/container.go index 45758d2..c43e201 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -22,9 +22,10 @@ type ContainerRunner struct { LogDir string APIURL string DropsDir string - SSHAuthSock string // optional path to host SSH agent - ClaudeBinary string // optional path to claude binary in container - GeminiBinary string // optional path to gemini binary in container + SSHAuthSock string // optional path to host SSH agent + ClaudeBinary string // optional path to claude binary in container + GeminiBinary string // optional path to gemini binary in container + ClaudeConfigDir string // host path to ~/.claude; mounted into container for auth credentials // Command allows mocking exec.CommandContext for tests. Command func(ctx context.Context, name string, arg ...string) *exec.Cmd } @@ -50,9 +51,14 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec repoURL = t.Agent.RepositoryURL } if repoURL == "" { - // Fallback to project_dir if repository_url is not set (legacy support) + // Fallback to project_dir if repository_url is not set (legacy support). + // Prefer the 'local' bare remote so that git push succeeds after execution + // (pushing to a non-bare working copy on a checked-out branch is rejected by git). if t.Agent.ProjectDir != "" { repoURL = t.Agent.ProjectDir + if out, err2 := exec.Command("git", "-C", t.Agent.ProjectDir, "remote", "get-url", "local").Output(); err2 == nil { + repoURL = strings.TrimSpace(string(out)) + } } else { return fmt.Errorf("task %s has no repository_url or project_dir", t.ID) } @@ -82,6 +88,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec if err != nil { return fmt.Errorf("creating workspace: %w", err) } + // chmod applied after clone; see step 2. } // Note: workspace is only removed on success. On failure, it's preserved for debugging. @@ -96,18 +103,18 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } }() - // 2. Clone repo into workspace if not resuming + // 2. Clone repo into workspace if not resuming. + // git clone requires the target directory to not exist; remove the MkdirTemp-created dir first. if !isResume { + if err := os.Remove(workspace); err != nil { + return fmt.Errorf("removing workspace before clone: %w", err) + } r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace) if out, err := r.command(ctx, "git", "clone", repoURL, workspace).CombinedOutput(); err != nil { - // If it looks like a remote URL, fail fast. - if strings.HasPrefix(repoURL, "http") || strings.HasPrefix(repoURL, "git@") || strings.HasPrefix(repoURL, "ssh://") { - return fmt.Errorf("git clone failed for remote repository: %w\n%s", err, string(out)) - } - 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)) - } + return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) + } + if err = os.Chmod(workspace, 0755); err != nil { + return fmt.Errorf("chmod cloned workspace: %w", err) } } e.SandboxDir = workspace @@ -140,18 +147,39 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec // 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")) + envContent := fmt.Sprintf("ANTHROPIC_API_KEY=%s\nGOOGLE_API_KEY=%s\nGEMINI_API_KEY=%s\n", os.Getenv("ANTHROPIC_API_KEY"), os.Getenv("GOOGLE_API_KEY"), os.Getenv("GEMINI_API_KEY")) if err := os.WriteFile(envFile, []byte(envContent), 0600); err != nil { return fmt.Errorf("writing env file: %w", err) } // 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 { + if err := os.WriteFile(instructionsFile, []byte(t.Agent.Instructions), 0644); err != nil { return fmt.Errorf("writing instructions: %w", err) } - args := r.buildDockerArgs(workspace, e.TaskID) + // Set up a writable $HOME staging dir so any agent tool (claude, gemini, etc.) + // can freely create subdirs (session-env, .gemini, .cache, …) without hitting + // a non-existent or read-only home. We copy only the claude credentials into it. + agentHome := filepath.Join(workspace, ".agent-home") + if err := os.MkdirAll(filepath.Join(agentHome, ".claude"), 0755); err != nil { + return fmt.Errorf("creating agent home staging dir: %w", err) + } + if err := os.MkdirAll(filepath.Join(agentHome, ".gemini"), 0755); err != nil { + return fmt.Errorf("creating .gemini dir: %w", err) + } + if r.ClaudeConfigDir != "" { + // credentials + if srcData, readErr := os.ReadFile(filepath.Join(r.ClaudeConfigDir, ".credentials.json")); readErr == nil { + _ = os.WriteFile(filepath.Join(agentHome, ".claude", ".credentials.json"), srcData, 0600) + } + // settings (used by claude CLI; copy so it can write updates without hitting the host) + if srcData, readErr := os.ReadFile(filepath.Join(filepath.Dir(r.ClaudeConfigDir), ".claude.json")); readErr == nil { + _ = os.WriteFile(filepath.Join(agentHome, ".claude.json"), srcData, 0644) + } + } + + args := r.buildDockerArgs(workspace, agentHome, e.TaskID) innerCmd := r.buildInnerCmd(t, e, isResume) fullArgs := append(args, image) @@ -240,9 +268,8 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec // 5. Post-execution: push changes if successful if waitErr == nil && streamErr == nil { - // Check if there are any commits to push (Issue 10) - // We use rev-list to see if HEAD is ahead of origin/HEAD. - // If origin/HEAD doesn't exist (e.g. fresh init), we just attempt to push. + // Check if there are any commits to push (HEAD ahead of origin/HEAD). + // If origin/HEAD doesn't exist (e.g. fresh clone with no commits), we attempt push anyway. hasCommits := true if out, err := r.command(ctx, "git", "-C", workspace, "rev-list", "origin/HEAD..HEAD").CombinedOutput(); err == nil { if len(strings.TrimSpace(string(out))) == 0 { @@ -272,15 +299,25 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec return nil } -func (r *ContainerRunner) buildDockerArgs(workspace, taskID string) []string { +func (r *ContainerRunner) buildDockerArgs(workspace, claudeHome, taskID string) []string { // --env-file takes a HOST path. hostEnvFile := filepath.Join(workspace, ".claudomator-env") + + // Replace localhost with host.docker.internal so the container can reach the host API. + apiURL := strings.ReplaceAll(r.APIURL, "localhost", "host.docker.internal") + args := []string{ "run", "--rm", + // Allow container to reach the host via host.docker.internal. + "--add-host=host.docker.internal:host-gateway", + // Run as the current process UID:GID so the container can read host-owned files. + fmt.Sprintf("--user=%d:%d", os.Getuid(), os.Getgid()), "-v", workspace + ":/workspace", + "-v", claudeHome + ":/home/agent", "-w", "/workspace", "--env-file", hostEnvFile, - "-e", "CLAUDOMATOR_API_URL=" + r.APIURL, + "-e", "HOME=/home/agent", + "-e", "CLAUDOMATOR_API_URL=" + apiURL, "-e", "CLAUDOMATOR_TASK_ID=" + taskID, "-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir, } diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index d4d591e..f97f2b5 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -23,14 +23,19 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) { workspace := "/tmp/ws" taskID := "task-123" - args := runner.buildDockerArgs(workspace, taskID) + agentHome := "/tmp/ws/.agent-home" + args := runner.buildDockerArgs(workspace, agentHome, taskID) expected := []string{ "run", "--rm", + "--add-host=host.docker.internal:host-gateway", + fmt.Sprintf("--user=%d:%d", os.Getuid(), os.Getgid()), "-v", "/tmp/ws:/workspace", + "-v", "/tmp/ws/.agent-home:/home/agent", "-w", "/workspace", "--env-file", "/tmp/ws/.claudomator-env", - "-e", "CLAUDOMATOR_API_URL=http://localhost:8484", + "-e", "HOME=/home/agent", + "-e", "CLAUDOMATOR_API_URL=http://host.docker.internal:8484", "-e", "CLAUDOMATOR_TASK_ID=task-123", "-e", "CLAUDOMATOR_DROP_DIR=/data/drops", "-v", "/tmp/ssh.sock:/tmp/ssh-auth.sock", diff --git a/scripts/drain-failed-tasks b/scripts/drain-failed-tasks new file mode 100644 index 0000000..4bb6992 --- /dev/null +++ b/scripts/drain-failed-tasks @@ -0,0 +1,22 @@ +#!/bin/bash +# drain-failed-tasks — retry failed tasks by running start-next-task every 5 minutes +# Usage: ./scripts/drain-failed-tasks [iterations] +# Default: 29 iterations + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ITERATIONS="${1:-29}" +INTERVAL=300 # 5 minutes + +echo "Running start-next-task every ${INTERVAL}s for ${ITERATIONS} iterations" + +for ((i=1; i<=ITERATIONS; i++)); do + echo "[$(date '+%H:%M:%S')] Iteration ${i}/${ITERATIONS}" + "$SCRIPT_DIR/start-next-task" || true + if [[ $i -lt $ITERATIONS ]]; then + sleep "$INTERVAL" + fi +done + +echo "[$(date '+%H:%M:%S')] Done." -- cgit v1.2.3