summaryrefslogtreecommitdiff
path: root/internal/executor
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-18 00:17:50 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-18 07:54:48 +0000
commit0fb4e3e81c20b2e2b58040772b747ec1dd9e09e7 (patch)
tree6a0b8af6c3faacc332e1102776960ac218ec66ca /internal/executor
parent1d550c1196ea836e0a0f798ba0127c1086f5f963 (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')
-rw-r--r--internal/executor/container.go172
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
+}