summaryrefslogtreecommitdiff
path: root/internal/executor/container.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/container.go')
-rw-r--r--internal/executor/container.go172
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
+}