summaryrefslogtreecommitdiff
path: root/internal/executor/claude_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/claude_test.go')
-rw-r--r--internal/executor/claude_test.go273
1 files changed, 273 insertions, 0 deletions
diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go
index 1f95b4a..b5f7962 100644
--- a/internal/executor/claude_test.go
+++ b/internal/executor/claude_test.go
@@ -2,8 +2,11 @@ package executor
import (
"context"
+ "errors"
"io"
"log/slog"
+ "os"
+ "os/exec"
"path/filepath"
"runtime"
"strings"
@@ -227,6 +230,42 @@ func TestClaudeRunner_BuildArgs_PreambleBashNotDuplicated(t *testing.T) {
}
}
+// TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession verifies that when a
+// resume execution is itself blocked again, the stored SessionID is the original
+// resumed session, not the new execution's own UUID. Without this, a second
+// block-and-resume cycle passes the wrong --resume session ID and fails.
+func TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession(t *testing.T) {
+ logDir := t.TempDir()
+ r := &ClaudeRunner{
+ BinaryPath: "true", // exits 0, no output
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ LogDir: logDir,
+ }
+ tk := &task.Task{
+ Agent: task.AgentConfig{
+ Type: "claude",
+ Instructions: "continue",
+ SkipPlanning: true,
+ },
+ }
+ exec := &storage.Execution{
+ ID: "resume-exec-uuid",
+ TaskID: "task-1",
+ ResumeSessionID: "original-session-uuid",
+ ResumeAnswer: "yes",
+ }
+
+ // Run completes successfully (binary is "true").
+ _ = r.Run(context.Background(), tk, exec)
+
+ // SessionID must be the original session (ResumeSessionID), not the new
+ // exec's own ID. If it were exec.ID, a second blocked-then-resumed cycle
+ // would use the wrong --resume argument and fail.
+ if exec.SessionID != "original-session-uuid" {
+ t.Errorf("SessionID after resume Run: want %q, got %q", "original-session-uuid", exec.SessionID)
+ }
+}
+
func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) {
r := &ClaudeRunner{
BinaryPath: "true", // would succeed if it ran
@@ -305,3 +344,237 @@ func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) {
baseline, after, after-baseline)
}
}
+
+// initGitRepo creates a git repo in dir with one commit so it is clonable.
+func initGitRepo(t *testing.T, dir string) {
+ t.Helper()
+ cmds := [][]string{
+ {"git", "-C", dir, "init"},
+ {"git", "-C", dir, "config", "user.email", "test@test"},
+ {"git", "-C", dir, "config", "user.name", "test"},
+ {"git", "-C", dir, "commit", "--allow-empty", "-m", "init"},
+ }
+ for _, args := range cmds {
+ if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+}
+
+func TestSandboxCloneSource_PrefersLocalRemote(t *testing.T) {
+ dir := t.TempDir()
+ initGitRepo(t, dir)
+ // Add a "local" remote pointing to a bare repo.
+ bare := t.TempDir()
+ exec.Command("git", "init", "--bare", bare).Run()
+ exec.Command("git", "-C", dir, "remote", "add", "local", bare).Run()
+ exec.Command("git", "-C", dir, "remote", "add", "origin", "https://example.com/repo").Run()
+
+ got := sandboxCloneSource(dir)
+ if got != bare {
+ t.Errorf("expected bare repo path %q, got %q", bare, got)
+ }
+}
+
+func TestSandboxCloneSource_FallsBackToOrigin(t *testing.T) {
+ dir := t.TempDir()
+ initGitRepo(t, dir)
+ originURL := "https://example.com/origin-repo"
+ exec.Command("git", "-C", dir, "remote", "add", "origin", originURL).Run()
+
+ got := sandboxCloneSource(dir)
+ if got != originURL {
+ t.Errorf("expected origin URL %q, got %q", originURL, got)
+ }
+}
+
+func TestSandboxCloneSource_FallsBackToProjectDir(t *testing.T) {
+ dir := t.TempDir()
+ initGitRepo(t, dir)
+ // No remotes configured.
+ got := sandboxCloneSource(dir)
+ if got != dir {
+ t.Errorf("expected projectDir %q (no remotes), got %q", dir, got)
+ }
+}
+
+func TestSetupSandbox_ClonesGitRepo(t *testing.T) {
+ src := t.TempDir()
+ initGitRepo(t, src)
+
+ sandbox, err := setupSandbox(src)
+ if err != nil {
+ t.Fatalf("setupSandbox: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(sandbox) })
+
+ // Verify sandbox is a git repo with at least one commit.
+ out, err := exec.Command("git", "-C", sandbox, "log", "--oneline").Output()
+ if err != nil {
+ t.Fatalf("git log in sandbox: %v", err)
+ }
+ if len(strings.TrimSpace(string(out))) == 0 {
+ t.Error("expected at least one commit in sandbox, got empty log")
+ }
+}
+
+func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) {
+ // A plain directory (not a git repo) should be initialised then cloned.
+ src := t.TempDir()
+
+ sandbox, err := setupSandbox(src)
+ if err != nil {
+ t.Fatalf("setupSandbox on plain dir: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(sandbox) })
+
+ if _, err := os.Stat(filepath.Join(sandbox, ".git")); err != nil {
+ t.Errorf("sandbox should be a git repo: %v", err)
+ }
+}
+
+func TestTeardownSandbox_UncommittedChanges_ReturnsError(t *testing.T) {
+ src := t.TempDir()
+ initGitRepo(t, src)
+ sandbox, err := setupSandbox(src)
+ if err != nil {
+ t.Fatalf("setupSandbox: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(sandbox) })
+
+ // Leave an uncommitted file in the sandbox.
+ if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("oops"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ err = teardownSandbox(src, sandbox, logger)
+ if err == nil {
+ t.Fatal("expected error for uncommitted changes, got nil")
+ }
+ if !strings.Contains(err.Error(), "uncommitted changes") {
+ t.Errorf("expected 'uncommitted changes' in error, got: %v", err)
+ }
+ // Sandbox should be preserved (not removed) on error.
+ if _, statErr := os.Stat(sandbox); os.IsNotExist(statErr) {
+ t.Error("sandbox was removed despite error; should be preserved for debugging")
+ }
+}
+
+func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) {
+ src := t.TempDir()
+ initGitRepo(t, src)
+ sandbox, err := setupSandbox(src)
+ if err != nil {
+ t.Fatalf("setupSandbox: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ // Sandbox has no new commits beyond origin; teardown should succeed and remove it.
+ if err := teardownSandbox(src, sandbox, logger); err != nil {
+ t.Fatalf("teardownSandbox: %v", err)
+ }
+ if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
+ t.Error("sandbox should have been removed after clean teardown")
+ os.RemoveAll(sandbox)
+ }
+}
+
+// TestBlockedError_IncludesSandboxDir verifies that when a task is blocked in a
+// sandbox, the BlockedError carries the sandbox path so the resume execution can
+// run in the same directory (where Claude's session files are stored).
+func TestBlockedError_IncludesSandboxDir(t *testing.T) {
+ src := t.TempDir()
+ initGitRepo(t, src)
+
+ logDir := t.TempDir()
+
+ // Use a script that writes question.json to the env-var path and exits 0
+ // (simulating a blocked agent that asks a question before exiting).
+ scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh")
+ if err := os.WriteFile(scriptPath, []byte(`#!/bin/sh
+if [ -n "$CLAUDOMATOR_QUESTION_FILE" ]; then
+ printf '{"question":"continue?"}' > "$CLAUDOMATOR_QUESTION_FILE"
+fi
+`), 0755); err != nil {
+ t.Fatalf("write script: %v", err)
+ }
+
+ r := &ClaudeRunner{
+ BinaryPath: scriptPath,
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ LogDir: logDir,
+ }
+ tk := &task.Task{
+ Agent: task.AgentConfig{
+ Type: "claude",
+ Instructions: "do something",
+ ProjectDir: src,
+ SkipPlanning: true,
+ },
+ }
+ exec := &storage.Execution{ID: "blocked-exec-uuid", TaskID: "task-1"}
+
+ err := r.Run(context.Background(), tk, exec)
+
+ var blocked *BlockedError
+ if !errors.As(err, &blocked) {
+ t.Fatalf("expected BlockedError, got: %v", err)
+ }
+ if blocked.SandboxDir == "" {
+ t.Error("BlockedError.SandboxDir should be set when task runs in a sandbox")
+ }
+ // Sandbox should still exist (preserved for resume).
+ if _, statErr := os.Stat(blocked.SandboxDir); os.IsNotExist(statErr) {
+ t.Error("sandbox directory should be preserved when blocked")
+ } else {
+ os.RemoveAll(blocked.SandboxDir) // cleanup
+ }
+}
+
+// TestClaudeRunner_Run_ResumeUsesStoredSandboxDir verifies that when a resume
+// execution has SandboxDir set, the runner uses that directory (not project_dir)
+// as the working directory, so Claude finds its session files there.
+func TestClaudeRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) {
+ logDir := t.TempDir()
+ sandboxDir := t.TempDir()
+ cwdFile := filepath.Join(logDir, "cwd.txt")
+
+ // Use a script that writes its working directory to a file in logDir (stable path).
+ scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh")
+ script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n"
+ if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
+ t.Fatalf("write script: %v", err)
+ }
+
+ r := &ClaudeRunner{
+ BinaryPath: scriptPath,
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ LogDir: logDir,
+ }
+ tk := &task.Task{
+ Agent: task.AgentConfig{
+ Type: "claude",
+ ProjectDir: sandboxDir, // must exist; resume overrides it with SandboxDir anyway
+ SkipPlanning: true,
+ },
+ }
+ exec := &storage.Execution{
+ ID: "resume-exec-uuid",
+ TaskID: "task-1",
+ ResumeSessionID: "original-session",
+ ResumeAnswer: "yes",
+ SandboxDir: sandboxDir,
+ }
+
+ _ = r.Run(context.Background(), tk, exec)
+
+ got, err := os.ReadFile(cwdFile)
+ if err != nil {
+ t.Fatalf("cwd file not written: %v", err)
+ }
+ // The runner should have executed claude in sandboxDir, not in project_dir.
+ if string(got) != sandboxDir {
+ t.Errorf("resume working dir: want %q, got %q", sandboxDir, string(got))
+ }
+}