From 6ff67a57d72317360cacd4b41560395ded117d20 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 15 Mar 2026 03:39:49 +0000 Subject: feat: fix task failures via sandbox improvements and display commits in Web UI - Fix ephemeral sandbox deletion issue by passing $CLAUDOMATOR_PROJECT_DIR to agents and using it for subtask project_dir. - Implement sandbox autocommit in teardown to prevent task failures from uncommitted work. - Track git commits created during executions and persist them in the DB. - Display git commits and changestats badges in the Web UI execution history. - Add badge counts to Web UI tabs for Interrupted, Ready, and Running states. - Improve scripts/next-task to handle QUEUED tasks and configurable DB path. --- internal/executor/claude.go | 77 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 8 deletions(-) (limited to 'internal/executor/claude.go') 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"), ) -- cgit v1.2.3