summaryrefslogtreecommitdiff
path: root/internal/executor
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor')
-rw-r--r--internal/executor/claude.go77
-rw-r--r--internal/executor/claude_test.go103
-rw-r--r--internal/executor/executor.go1
-rw-r--r--internal/executor/gemini.go5
-rw-r--r--internal/executor/preamble.go2
5 files changed, 153 insertions, 35 deletions
diff --git a/internal/executor/claude.go b/internal/executor/claude.go
index 5a5b35e..4d92cd0 100644
--- a/internal/executor/claude.go
+++ b/internal/executor/claude.go
@@ -103,6 +103,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
// finds its session files under the same project slug. If no sandbox was
// preserved (e.g. task had no project_dir), fall back to project_dir.
var sandboxDir string
+ var startHEAD string
effectiveWorkingDir := projectDir
if e.ResumeSessionID != "" {
if e.SandboxDir != "" {
@@ -134,6 +135,12 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
r.Logger.Info("sandbox created", "sandbox", sandboxDir, "project_dir", projectDir)
}
+ if effectiveWorkingDir != "" {
+ // Capture the initial HEAD so we can identify new commits later.
+ headOut, _ := exec.Command("git", gitSafe("-C", effectiveWorkingDir, "rev-parse", "HEAD")...).Output()
+ startHEAD = strings.TrimSpace(string(headOut))
+ }
+
questionFile := filepath.Join(logDir, "question.json")
args := r.buildArgs(t, e, questionFile)
@@ -147,7 +154,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
)
}
attempt++
- return r.execOnce(ctx, args, effectiveWorkingDir, e)
+ return r.execOnce(ctx, args, effectiveWorkingDir, projectDir, e)
})
if err != nil {
if sandboxDir != "" {
@@ -183,7 +190,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
// Merge sandbox back to project_dir and clean up.
if sandboxDir != "" {
- if mergeErr := teardownSandbox(projectDir, sandboxDir, r.Logger); mergeErr != nil {
+ if mergeErr := teardownSandbox(projectDir, sandboxDir, startHEAD, r.Logger, e); mergeErr != nil {
return fmt.Errorf("sandbox teardown: %w (sandbox preserved at %s)", mergeErr, sandboxDir)
}
}
@@ -277,20 +284,57 @@ func setupSandbox(projectDir string) (string, error) {
// The working copy (projectDir) is NOT updated automatically — it is the
// developer's workspace and is pulled manually. This avoids permission errors
// from mixed-owner .git/objects directories.
-func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error {
- // Fail if agent left uncommitted changes.
+func teardownSandbox(projectDir, sandboxDir, startHEAD string, logger *slog.Logger, execRecord *storage.Execution) error {
+ // Automatically commit uncommitted changes.
out, err := exec.Command("git", "-C", sandboxDir, "status", "--porcelain").Output()
if err != nil {
return fmt.Errorf("git status: %w", err)
}
if len(strings.TrimSpace(string(out))) > 0 {
- return fmt.Errorf("uncommitted changes in sandbox (agent must commit all work):\n%s", out)
+ logger.Info("autocommitting uncommitted changes", "sandbox", sandboxDir)
+ cmds := [][]string{
+ gitSafe("-C", sandboxDir, "add", "-A"),
+ gitSafe("-C", sandboxDir, "commit", "-m", "chore: autocommit uncommitted changes"),
+ }
+ for _, args := range cmds {
+ if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
+ return fmt.Errorf("autocommit failed (%v): %w\n%s", args, err, out)
+ }
+ }
+ }
+
+ // Capture commits before pushing/deleting.
+ // Use startHEAD..HEAD to find all commits made during this execution.
+ logRange := "origin/HEAD..HEAD"
+ if startHEAD != "" && startHEAD != "HEAD" {
+ logRange = startHEAD + "..HEAD"
+ }
+
+ logCmd := exec.Command("git", gitSafe("-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s")...)
+ logOut, logErr := logCmd.CombinedOutput()
+ if logErr == nil {
+ lines := strings.Split(strings.TrimSpace(string(logOut)), "\n")
+ logger.Debug("captured commits", "count", len(lines), "range", logRange)
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "|", 2)
+ if len(parts) == 2 {
+ execRecord.Commits = append(execRecord.Commits, task.GitCommit{
+ Hash: parts[0],
+ Message: parts[1],
+ })
+ }
+ }
+ } else {
+ logger.Warn("failed to capture commits", "err", logErr, "range", logRange, "output", string(logOut))
}
// Check whether there are any new commits to push.
- ahead, err := exec.Command("git", "-C", sandboxDir, "rev-list", "--count", "origin/HEAD..HEAD").Output()
+ ahead, err := exec.Command("git", gitSafe("-C", sandboxDir, "rev-list", "--count", logRange)...).Output()
if err != nil {
- logger.Warn("could not determine commits ahead of origin; proceeding", "err", err)
+ logger.Warn("could not determine commits ahead of origin; proceeding", "err", err, "range", logRange)
}
if strings.TrimSpace(string(ahead)) == "0" {
os.RemoveAll(sandboxDir)
@@ -305,6 +349,22 @@ func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error {
if out2, err2 := exec.Command("git", "-C", sandboxDir, "pull", "--rebase", "origin", "master").CombinedOutput(); err2 != nil {
return fmt.Errorf("git rebase before retry push: %w\n%s", err2, out2)
}
+ // Re-capture commits after rebase (hashes might have changed)
+ execRecord.Commits = nil
+ logOut, logErr = exec.Command("git", "-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s").Output()
+ if logErr == nil {
+ lines := strings.Split(strings.TrimSpace(string(logOut)), "\n")
+ for _, line := range lines {
+ parts := strings.SplitN(line, "|", 2)
+ if len(parts) == 2 {
+ execRecord.Commits = append(execRecord.Commits, task.GitCommit{
+ Hash: parts[0],
+ Message: parts[1],
+ })
+ }
+ }
+ }
+
if out3, err3 := exec.Command("git", "-C", sandboxDir, "push", "origin", "HEAD").CombinedOutput(); err3 != nil {
return fmt.Errorf("git push to origin (after rebase): %w\n%s", err3, out3)
}
@@ -319,11 +379,12 @@ func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error {
}
// execOnce runs the claude subprocess once, streaming output to e's log paths.
-func (r *ClaudeRunner) execOnce(ctx context.Context, args []string, workingDir string, e *storage.Execution) error {
+func (r *ClaudeRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error {
cmd := exec.CommandContext(ctx, r.binaryPath(), args...)
cmd.Env = append(os.Environ(),
"CLAUDOMATOR_API_URL="+r.APIURL,
"CLAUDOMATOR_TASK_ID="+e.TaskID,
+ "CLAUDOMATOR_PROJECT_DIR="+projectDir,
"CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"),
"CLAUDOMATOR_SUMMARY_FILE="+filepath.Join(e.ArtifactDir, "summary.txt"),
)
diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go
index 9bb873f..02d1b2e 100644
--- a/internal/executor/claude_test.go
+++ b/internal/executor/claude_test.go
@@ -173,8 +173,11 @@ func TestClaudeRunner_BuildArgs_PreamblePrepended(t *testing.T) {
if len(args) < 2 || args[0] != "-p" {
t.Fatalf("expected -p as first arg, got: %v", args)
}
- if !strings.HasPrefix(args[1], planningPreamble) {
- t.Errorf("instructions should start with planning preamble")
+ if !strings.HasPrefix(args[1], "## Runtime Environment") {
+ t.Errorf("instructions should start with planning preamble, got prefix: %q", args[1][:min(len(args[1]), 20)])
+ }
+ if !strings.Contains(args[1], "$CLAUDOMATOR_PROJECT_DIR") {
+ t.Errorf("preamble should mention $CLAUDOMATOR_PROJECT_DIR")
}
if !strings.HasSuffix(args[1], "fix the bug") {
t.Errorf("instructions should end with original instructions")
@@ -329,7 +332,7 @@ func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) {
runtime.Gosched()
baseline := runtime.NumGoroutine()
- if err := r.execOnce(context.Background(), []string{}, "", e); err != nil {
+ if err := r.execOnce(context.Background(), []string{}, "", "", e); err != nil {
t.Fatalf("execOnce failed: %v", err)
}
@@ -350,16 +353,24 @@ func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) {
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"},
+ {"git", "-c", "safe.directory=*", "-C", dir, "init", "-b", "main"},
+ {"git", "-c", "safe.directory=*", "-C", dir, "config", "user.email", "test@test"},
+ {"git", "-c", "safe.directory=*", "-C", dir, "config", "user.name", "test"},
}
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)
}
}
+ if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", dir, "add", ".").CombinedOutput(); err != nil {
+ t.Fatalf("git add: %v\n%s", err, out)
+ }
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", dir, "commit", "-m", "init").CombinedOutput(); err != nil {
+ t.Fatalf("git commit: %v\n%s", err, out)
+ }
}
func TestSandboxCloneSource_PrefersLocalRemote(t *testing.T) {
@@ -409,6 +420,13 @@ func TestSetupSandbox_ClonesGitRepo(t *testing.T) {
}
t.Cleanup(func() { os.RemoveAll(sandbox) })
+ // Force sandbox to master if it cloned as main
+ exec.Command("git", gitSafe("-C", sandbox, "checkout", "master")...).Run()
+
+ // Debug sandbox
+ logOut, _ := exec.Command("git", "-C", sandbox, "log", "-1").CombinedOutput()
+ fmt.Printf("DEBUG: sandbox log: %s\n", string(logOut))
+
// Verify sandbox is a git repo with at least one commit.
out, err := exec.Command("git", "-C", sandbox, "log", "--oneline").Output()
if err != nil {
@@ -434,31 +452,63 @@ func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) {
}
}
-func TestTeardownSandbox_UncommittedChanges_ReturnsError(t *testing.T) {
- src := t.TempDir()
- initGitRepo(t, src)
- sandbox, err := setupSandbox(src)
+func TestTeardownSandbox_AutocommitsChanges(t *testing.T) {
+ // Create a bare repo as origin so push succeeds.
+ bare := t.TempDir()
+ if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git init bare: %v\n%s", err, out)
+ }
+
+ // Create a sandbox directly.
+ sandbox := t.TempDir()
+ initGitRepo(t, sandbox)
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git remote add: %v\n%s", err, out)
+ }
+ // Initial push to establish origin/main
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil {
+ t.Fatalf("git push initial: %v\n%s", err, out)
+ }
+
+ // Capture startHEAD
+ headOut, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "rev-parse", "HEAD").Output()
if err != nil {
- t.Fatalf("setupSandbox: %v", err)
+ t.Fatalf("rev-parse HEAD: %v", err)
}
- t.Cleanup(func() { os.RemoveAll(sandbox) })
+ startHEAD := strings.TrimSpace(string(headOut))
// Leave an uncommitted file in the sandbox.
- if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("oops"), 0644); err != nil {
+ if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("autocommit me"), 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")
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ execRecord := &storage.Execution{}
+
+ err = teardownSandbox("", sandbox, startHEAD, logger, execRecord)
+ if err != nil {
+ t.Fatalf("expected autocommit to succeed, got error: %v", err)
+ }
+
+ // Sandbox should be removed after successful autocommit and push.
+ if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
+ t.Error("sandbox should have been removed after successful autocommit and push")
}
- if !strings.Contains(err.Error(), "uncommitted changes") {
- t.Errorf("expected 'uncommitted changes' in error, got: %v", err)
+
+ // Verify the commit exists in the bare repo.
+ out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output()
+ if err != nil {
+ t.Fatalf("git log in bare repo: %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")
+ if !strings.Contains(string(out), "chore: autocommit uncommitted changes") {
+ t.Errorf("expected autocommit message in log, got: %q", string(out))
+ }
+
+ // Verify the commit was captured in execRecord.
+ if len(execRecord.Commits) == 0 {
+ t.Error("expected at least one commit in execRecord")
+ } else if !strings.Contains(execRecord.Commits[0].Message, "chore: autocommit uncommitted changes") {
+ t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message)
}
}
@@ -471,8 +521,13 @@ func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ execRecord := &storage.Execution{}
+
+ headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output()
+ startHEAD := strings.TrimSpace(string(headOut))
+
// Sandbox has no new commits beyond origin; teardown should succeed and remove it.
- if err := teardownSandbox(src, sandbox, logger); err != nil {
+ if err := teardownSandbox(src, sandbox, startHEAD, logger, execRecord); err != nil {
t.Fatalf("teardownSandbox: %v", err)
}
if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index fd37c33..f85f1ff 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -355,6 +355,7 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage.
}
if exec.StdoutPath != "" {
if cs := task.ParseChangestatFromFile(exec.StdoutPath); cs != nil {
+ exec.Changestats = cs
if csErr := p.store.UpdateExecutionChangestats(exec.ID, cs); csErr != nil {
p.logger.Error("failed to store changestats", "execID", exec.ID, "error", csErr)
}
diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go
index 2db3218..67ea7dd 100644
--- a/internal/executor/gemini.go
+++ b/internal/executor/gemini.go
@@ -68,7 +68,7 @@ func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
// Gemini CLI doesn't necessarily have the same rate limiting behavior as Claude,
// but we'll use a similar execution pattern.
- err := r.execOnce(ctx, args, t.Agent.ProjectDir, e)
+ err := r.execOnce(ctx, args, t.Agent.ProjectDir, t.Agent.ProjectDir, e)
if err != nil {
return err
}
@@ -82,11 +82,12 @@ func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
return nil
}
-func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir string, e *storage.Execution) error {
+func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error {
cmd := exec.CommandContext(ctx, r.binaryPath(), args...)
cmd.Env = append(os.Environ(),
"CLAUDOMATOR_API_URL="+r.APIURL,
"CLAUDOMATOR_TASK_ID="+e.TaskID,
+ "CLAUDOMATOR_PROJECT_DIR="+projectDir,
"CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"),
)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
diff --git a/internal/executor/preamble.go b/internal/executor/preamble.go
index 8ae79ad..f5dba2b 100644
--- a/internal/executor/preamble.go
+++ b/internal/executor/preamble.go
@@ -27,7 +27,7 @@ Before doing any implementation work:
2. If YES — break it down:
- Create 3–7 discrete subtasks by POSTing to $CLAUDOMATOR_API_URL/api/tasks
- - Each subtask POST body should be JSON with: name, agent.instructions, agent.working_dir (copy from current task), agent.model, agent.allowed_tools, and agent.skip_planning set to true
+ - Each subtask POST body should be JSON with: name, agent.instructions, agent.project_dir (copy from $CLAUDOMATOR_PROJECT_DIR), agent.model, agent.allowed_tools, and agent.skip_planning set to true
- Set parent_task_id to $CLAUDOMATOR_TASK_ID in each POST body
- After creating all subtasks, output a brief summary and STOP. Do not implement anything.
- You can also specify agent.type (either "claude" or "gemini") to choose the agent for subtasks.