diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-18 00:17:50 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-18 07:54:48 +0000 |
| commit | 0fb4e3e81c20b2e2b58040772b747ec1dd9e09e7 (patch) | |
| tree | 6a0b8af6c3faacc332e1102776960ac218ec66ca /internal/executor/container.go | |
| parent | 1d550c1196ea836e0a0f798ba0127c1086f5f963 (diff) | |
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.
Diffstat (limited to 'internal/executor/container.go')
| -rw-r--r-- | internal/executor/container.go | 172 |
1 files changed, 172 insertions, 0 deletions
diff --git a/internal/executor/container.go b/internal/executor/container.go new file mode 100644 index 0000000..e148620 --- /dev/null +++ b/internal/executor/container.go @@ -0,0 +1,172 @@ +package executor + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sync" + "syscall" + + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// 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 +} + +func (r *ContainerRunner) ExecLogDir(execID string) string { + if r.LogDir == "" { + return "" + } + return filepath.Join(r.LogDir, execID) +} + +func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { + repoURL := t.RepositoryURL + if repoURL == "" { + // Fallback to project_dir if repository_url is not set (legacy support) + if t.Agent.ProjectDir != "" { + repoURL = t.Agent.ProjectDir + } else { + return fmt.Errorf("task %s has no repository_url or project_dir", t.ID) + } + } + + image := t.Agent.ContainerImage + if image == "" { + image = r.Image + } + if image == "" { + image = "claudomator-agent:latest" + } + + // 1. Setup workspace on host + workspace, err := os.MkdirTemp("", "claudomator-workspace-*") + if err != nil { + return fmt.Errorf("creating workspace: %w", err) + } + 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)) + } + + // 3. Prepare logs + logDir := r.ExecLogDir(e.ID) + if logDir == "" { + logDir = filepath.Join(workspace, ".claudomator-logs") + } + if err := os.MkdirAll(logDir, 0700); err != nil { + return fmt.Errorf("creating log dir: %w", err) + } + e.StdoutPath = filepath.Join(logDir, "stdout.log") + e.StderrPath = filepath.Join(logDir, "stderr.log") + e.ArtifactDir = logDir + + 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() + + // 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"), + } + + // Inject custom instructions as environment variable or via file + 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 = append(args, image) + args = append(args, innerCmd...) + + r.Logger.Info("starting container", "image", image, "taskID", t.ID) + cmd := exec.CommandContext(ctx, "docker", args...) + 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() + if err != nil { + return fmt.Errorf("creating stdout pipe: %w", err) + } + cmd.Stdout = stdoutW + + if err := cmd.Start(); err != nil { + stdoutW.Close() + stdoutR.Close() + return fmt.Errorf("starting container: %w", err) + } + stdoutW.Close() + + // Stream stdout to the log file and parse cost/errors. + var costUSD float64 + var streamErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + costUSD, streamErr = parseStream(stdoutR, stdoutFile, r.Logger) + stdoutR.Close() + }() + + waitErr := cmd.Wait() + wg.Wait() + + e.CostUSD = costUSD + + // 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)) + } + } + + if waitErr != nil { + return fmt.Errorf("container execution failed: %w", waitErr) + } + + return nil +} |
