summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md14
-rw-r--r--internal/api/changestats.go15
-rw-r--r--internal/api/server.go9
-rw-r--r--internal/api/server_test.go125
-rw-r--r--internal/executor/claude.go77
-rw-r--r--internal/executor/claude_test.go103
-rw-r--r--internal/executor/executor.go9
-rw-r--r--internal/executor/executor_test.go154
-rw-r--r--internal/executor/gemini.go5
-rw-r--r--internal/executor/preamble.go2
-rw-r--r--internal/storage/db.go85
-rw-r--r--internal/storage/db_test.go42
-rw-r--r--internal/task/changestats.go47
-rw-r--r--internal/task/task.go13
-rwxr-xr-xscripts/next-task7
-rw-r--r--test/next-task.test.sh43
-rw-r--r--web/app.js52
-rw-r--r--web/style.css40
-rw-r--r--web/test/changestats.test.mjs125
19 files changed, 917 insertions, 50 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 1d03678..16a48b4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -105,6 +105,20 @@ Batch files wrap multiple tasks under a `tasks:` key.
Two tables: `tasks` (with `config_json`, `retry_json`, `tags_json`, `depends_on_json` as JSON blobs) and `executions` (with paths to log files). Schema is auto-migrated on `storage.Open()`.
+## Features
+
+### Changestats
+
+After each task execution, Claudomator extracts git diff statistics from the execution's stdout log. If the log contains a git `diff --stat` summary line (e.g. `5 files changed, 127 insertions(+), 43 deletions(-)`), the stats are parsed and stored in the `executions.changestats_json` column via `storage.DB.UpdateExecutionChangestats`.
+
+**Extraction points:**
+- `internal/executor.Pool.handleRunResult` — calls `task.ParseChangestatFromFile(exec.StdoutPath)` after every execution; stores via `Store.UpdateExecutionChangestats`.
+- `internal/api.Server.processResult` — also extracts changestats when the API server processes a result (same file, idempotent second write).
+
+**Parser location:** `internal/task/changestats.go` — exported functions `ParseChangestatFromOutput` and `ParseChangestatFromFile` usable by any package without creating circular imports.
+
+**Frontend display:** `web/app.js` renders a `.changestats-badge` on COMPLETED/READY task cards and in execution history rows.
+
## ADRs
See `docs/adr/001-language-and-architecture.md` for the Go + SQLite + WebSocket rationale.
diff --git a/internal/api/changestats.go b/internal/api/changestats.go
new file mode 100644
index 0000000..4f18f7f
--- /dev/null
+++ b/internal/api/changestats.go
@@ -0,0 +1,15 @@
+package api
+
+import "github.com/thepeterstone/claudomator/internal/task"
+
+// parseChangestatFromOutput delegates to task.ParseChangestatFromOutput.
+// Kept as a package-local wrapper for use within the api package.
+func parseChangestatFromOutput(output string) *task.Changestats {
+ return task.ParseChangestatFromOutput(output)
+}
+
+// parseChangestatFromFile delegates to task.ParseChangestatFromFile.
+// Kept as a package-local wrapper for use within the api package.
+func parseChangestatFromFile(path string) *task.Changestats {
+ return task.ParseChangestatFromFile(path)
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 163f2b8..8290738 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -128,7 +128,16 @@ func (s *Server) forwardResults() {
}
// processResult broadcasts a task completion event via WebSocket and calls the notifier if set.
+// It also parses git diff stats from the execution stdout log and persists them.
func (s *Server) processResult(result *executor.Result) {
+ if result.Execution.StdoutPath != "" {
+ if stats := parseChangestatFromFile(result.Execution.StdoutPath); stats != nil {
+ if err := s.store.UpdateExecutionChangestats(result.Execution.ID, stats); err != nil {
+ s.logger.Error("failed to store changestats", "execID", result.Execution.ID, "error", err)
+ }
+ }
+ }
+
event := map[string]interface{}{
"type": "task_completed",
"task_id": result.TaskID,
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index ec927c0..d090313 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -1433,3 +1433,128 @@ func TestRunTask_AgentCancelled_TaskSetToCancelled(t *testing.T) {
t.Errorf("task state: want CANCELLED, got %v", got)
}
}
+
+// TestGetTask_IncludesChangestats verifies that after processResult parses git diff stats
+// from the execution stdout log, they appear in the execution history response.
+func TestGetTask_IncludesChangestats(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "cs-task-1", task.StateCompleted)
+
+ // Write a stdout log with a git diff --stat summary line.
+ dir := t.TempDir()
+ stdoutPath := filepath.Join(dir, "stdout.log")
+ logContent := "Agent output line 1\n3 files changed, 50 insertions(+), 10 deletions(-)\nAgent output line 2\n"
+ if err := os.WriteFile(stdoutPath, []byte(logContent), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ exec := &storage.Execution{
+ ID: "cs-exec-1",
+ TaskID: tk.ID,
+ StartTime: time.Now().UTC(),
+ EndTime: time.Now().UTC().Add(time.Minute),
+ Status: "COMPLETED",
+ StdoutPath: stdoutPath,
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatal(err)
+ }
+
+ // processResult should parse changestats from the stdout log and store them.
+ result := &executor.Result{
+ TaskID: tk.ID,
+ Execution: exec,
+ }
+ srv.processResult(result)
+
+ // GET the task's execution history and assert changestats are populated.
+ req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID+"/executions", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ var execs []map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&execs); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(execs) != 1 {
+ t.Fatalf("want 1 execution, got %d", len(execs))
+ }
+
+ csVal, ok := execs[0]["Changestats"]
+ if !ok || csVal == nil {
+ t.Fatal("execution missing Changestats field after processResult")
+ }
+ csMap, ok := csVal.(map[string]interface{})
+ if !ok {
+ t.Fatalf("Changestats is not an object: %T", csVal)
+ }
+ if csMap["files_changed"].(float64) != 3 {
+ t.Errorf("files_changed: want 3, got %v", csMap["files_changed"])
+ }
+ if csMap["lines_added"].(float64) != 50 {
+ t.Errorf("lines_added: want 50, got %v", csMap["lines_added"])
+ }
+ if csMap["lines_removed"].(float64) != 10 {
+ t.Errorf("lines_removed: want 10, got %v", csMap["lines_removed"])
+ }
+}
+
+// TestListExecutions_IncludesChangestats verifies that changestats stored on an execution
+// are returned correctly by GET /api/tasks/{id}/executions.
+func TestListExecutions_IncludesChangestats(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "cs-task-2", task.StateCompleted)
+
+ cs := &task.Changestats{FilesChanged: 2, LinesAdded: 100, LinesRemoved: 20}
+ exec := &storage.Execution{
+ ID: "cs-exec-2",
+ TaskID: tk.ID,
+ StartTime: time.Now().UTC(),
+ EndTime: time.Now().UTC().Add(time.Minute),
+ Status: "COMPLETED",
+ Changestats: cs,
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID+"/executions", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ var execs []map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&execs); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(execs) != 1 {
+ t.Fatalf("want 1 execution, got %d", len(execs))
+ }
+
+ csVal, ok := execs[0]["Changestats"]
+ if !ok || csVal == nil {
+ t.Fatal("execution missing Changestats field")
+ }
+ csMap, ok := csVal.(map[string]interface{})
+ if !ok {
+ t.Fatalf("Changestats is not an object: %T", csVal)
+ }
+ if csMap["files_changed"].(float64) != 2 {
+ t.Errorf("files_changed: want 2, got %v", csMap["files_changed"])
+ }
+ if csMap["lines_added"].(float64) != 100 {
+ t.Errorf("lines_added: want 100, got %v", csMap["lines_added"])
+ }
+ if csMap["lines_removed"].(float64) != 20 {
+ t.Errorf("lines_removed: want 20, got %v", csMap["lines_removed"])
+ }
+}
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 7674fe6..f85f1ff 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -29,6 +29,7 @@ type Store interface {
UpdateTaskSummary(taskID, summary string) error
AppendTaskInteraction(taskID string, interaction task.Interaction) error
UpdateTaskAgent(id string, agent task.AgentConfig) error
+ UpdateExecutionChangestats(execID string, stats *task.Changestats) error
}
// LogPather is an optional interface runners can implement to provide the log
@@ -352,6 +353,14 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage.
p.logger.Error("failed to update task summary", "taskID", t.ID, "error", summaryErr)
}
}
+ 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)
+ }
+ }
+ }
if updateErr := p.store.UpdateExecution(exec); updateErr != nil {
p.logger.Error("failed to update execution", "error", updateErr)
}
diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go
index a6c4ad8..610ed3b 100644
--- a/internal/executor/executor_test.go
+++ b/internal/executor/executor_test.go
@@ -921,9 +921,13 @@ type minimalMockStore struct {
executions map[string]*storage.Execution
stateUpdates []struct{ id string; state task.State }
questionUpdates []string
- subtasksFunc func(parentID string) ([]*task.Task, error)
- updateExecErr error
- updateStateErr error
+ changestatCalls []struct {
+ execID string
+ stats *task.Changestats
+ }
+ subtasksFunc func(parentID string) ([]*task.Task, error)
+ updateExecErr error
+ updateStateErr error
}
func newMinimalMockStore() *minimalMockStore {
@@ -977,6 +981,15 @@ func (m *minimalMockStore) AppendTaskInteraction(taskID string, _ task.Interacti
return nil
}
func (m *minimalMockStore) UpdateTaskAgent(id string, agent task.AgentConfig) error { return nil }
+func (m *minimalMockStore) UpdateExecutionChangestats(execID string, stats *task.Changestats) error {
+ m.mu.Lock()
+ m.changestatCalls = append(m.changestatCalls, struct {
+ execID string
+ stats *task.Changestats
+ }{execID, stats})
+ m.mu.Unlock()
+ return nil
+}
func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) {
m.mu.Lock()
@@ -1203,3 +1216,138 @@ func TestPool_SpecificAgent_PersistsToDB(t *testing.T) {
t.Errorf("expected agent type gemini in DB, got %q", reloaded.Agent.Type)
}
}
+
+// TestExecute_ExtractAndStoreChangestats verifies that when the execution stdout
+// contains a git diff --stat summary line, the changestats are parsed and stored.
+func TestExecute_ExtractAndStoreChangestats(t *testing.T) {
+ store := testStore(t)
+ logDir := t.TempDir()
+
+ runner := &logPatherMockRunner{logDir: logDir}
+ runner.onRun = func(tk *task.Task, e *storage.Execution) error {
+ if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil {
+ return err
+ }
+ content := "some output\n5 files changed, 127 insertions(+), 43 deletions(-)\n"
+ return os.WriteFile(e.StdoutPath, []byte(content), 0644)
+ }
+
+ runners := map[string]Runner{"claude": runner}
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ pool := NewPool(2, runners, store, logger)
+
+ tk := makeTask("cs-extract-1")
+ store.CreateTask(tk)
+
+ if err := pool.Submit(context.Background(), tk); err != nil {
+ t.Fatalf("submit: %v", err)
+ }
+ result := <-pool.Results()
+ if result.Err != nil {
+ t.Fatalf("unexpected error: %v", result.Err)
+ }
+
+ execs, err := store.ListExecutions(tk.ID)
+ if err != nil {
+ t.Fatalf("list executions: %v", err)
+ }
+ if len(execs) == 0 {
+ t.Fatal("no executions found")
+ }
+ cs := execs[0].Changestats
+ if cs == nil {
+ t.Fatal("expected changestats to be populated, got nil")
+ }
+ if cs.FilesChanged != 5 {
+ t.Errorf("FilesChanged: want 5, got %d", cs.FilesChanged)
+ }
+ if cs.LinesAdded != 127 {
+ t.Errorf("LinesAdded: want 127, got %d", cs.LinesAdded)
+ }
+ if cs.LinesRemoved != 43 {
+ t.Errorf("LinesRemoved: want 43, got %d", cs.LinesRemoved)
+ }
+}
+
+// TestExecute_NoChangestats verifies that when execution output contains no git
+// diff stat line, changestats are not stored (remain nil).
+func TestExecute_NoChangestats(t *testing.T) {
+ store := testStore(t)
+ logDir := t.TempDir()
+
+ runner := &logPatherMockRunner{logDir: logDir}
+ runner.onRun = func(tk *task.Task, e *storage.Execution) error {
+ if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(e.StdoutPath, []byte("no git output here\n"), 0644)
+ }
+
+ runners := map[string]Runner{"claude": runner}
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ pool := NewPool(2, runners, store, logger)
+
+ tk := makeTask("cs-none-1")
+ store.CreateTask(tk)
+
+ if err := pool.Submit(context.Background(), tk); err != nil {
+ t.Fatalf("submit: %v", err)
+ }
+ result := <-pool.Results()
+ if result.Err != nil {
+ t.Fatalf("unexpected error: %v", result.Err)
+ }
+
+ execs, err := store.ListExecutions(tk.ID)
+ if err != nil {
+ t.Fatalf("list executions: %v", err)
+ }
+ if len(execs) == 0 {
+ t.Fatal("no executions found")
+ }
+ if execs[0].Changestats != nil {
+ t.Errorf("expected changestats to be nil for output with no git stats, got %+v", execs[0].Changestats)
+ }
+}
+
+// TestExecute_MalformedChangestats verifies that malformed git-stat-like output
+// does not produce changestats (parser returns nil, nothing is stored).
+func TestExecute_MalformedChangestats(t *testing.T) {
+ store := testStore(t)
+ logDir := t.TempDir()
+
+ runner := &logPatherMockRunner{logDir: logDir}
+ runner.onRun = func(tk *task.Task, e *storage.Execution) error {
+ if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil {
+ return err
+ }
+ // Looks like a git stat line but doesn't match the regex.
+ return os.WriteFile(e.StdoutPath, []byte("lots of cheese changed, many insertions\n"), 0644)
+ }
+
+ runners := map[string]Runner{"claude": runner}
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ pool := NewPool(2, runners, store, logger)
+
+ tk := makeTask("cs-malformed-1")
+ store.CreateTask(tk)
+
+ if err := pool.Submit(context.Background(), tk); err != nil {
+ t.Fatalf("submit: %v", err)
+ }
+ result := <-pool.Results()
+ if result.Err != nil {
+ t.Fatalf("unexpected error: %v", result.Err)
+ }
+
+ execs, err := store.ListExecutions(tk.ID)
+ if err != nil {
+ t.Fatalf("list executions: %v", err)
+ }
+ if len(execs) == 0 {
+ t.Fatal("no executions found")
+ }
+ if execs[0].Changestats != nil {
+ t.Errorf("expected nil changestats for malformed output, got %+v", execs[0].Changestats)
+ }
+}
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.
diff --git a/internal/storage/db.go b/internal/storage/db.go
index 043009c..69bcf68 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -83,6 +83,8 @@ func (s *DB) migrate() error {
`ALTER TABLE executions ADD COLUMN sandbox_dir TEXT`,
`ALTER TABLE tasks ADD COLUMN summary TEXT`,
`ALTER TABLE tasks ADD COLUMN interactions_json TEXT NOT NULL DEFAULT '[]'`,
+ `ALTER TABLE executions ADD COLUMN changestats_json TEXT`,
+ `ALTER TABLE executions ADD COLUMN commits_json TEXT NOT NULL DEFAULT '[]'`,
}
for _, m := range migrations {
if _, err := s.db.Exec(m); err != nil {
@@ -366,6 +368,9 @@ type Execution struct {
SessionID string // claude --session-id; persisted for resume
SandboxDir string // preserved sandbox path when task is BLOCKED; resume must run here
+ Changestats *task.Changestats // stored as JSON; nil if not yet recorded
+ Commits []task.GitCommit // stored as JSON; empty if no commits
+
// In-memory only: set when creating a resume execution, not stored in DB.
ResumeSessionID string
ResumeAnswer string
@@ -375,24 +380,41 @@ type Execution struct {
// CreateExecution inserts an execution record.
func (s *DB) CreateExecution(e *Execution) error {
+ var changestatsJSON *string
+ if e.Changestats != nil {
+ b, err := json.Marshal(e.Changestats)
+ if err != nil {
+ return fmt.Errorf("marshaling changestats: %w", err)
+ }
+ s := string(b)
+ changestatsJSON = &s
+ }
+ commitsJSON := "[]"
+ if len(e.Commits) > 0 {
+ b, err := json.Marshal(e.Commits)
+ if err != nil {
+ return fmt.Errorf("marshaling commits: %w", err)
+ }
+ commitsJSON = string(b)
+ }
_, err := s.db.Exec(`
- INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status,
- e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir,
+ e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON,
)
return err
}
// GetExecution retrieves an execution by ID.
func (s *DB) GetExecution(id string) (*Execution, error) {
- row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir FROM executions WHERE id = ?`, id)
+ row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE id = ?`, id)
return scanExecution(row)
}
// ListExecutions returns executions for a task.
func (s *DB) ListExecutions(taskID string) ([]*Execution, error) {
- rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID)
+ rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID)
if err != nil {
return nil, err
}
@@ -411,7 +433,7 @@ func (s *DB) ListExecutions(taskID string) ([]*Execution, error) {
// GetLatestExecution returns the most recent execution for a task.
func (s *DB) GetLatestExecution(taskID string) (*Execution, error) {
- row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID)
+ row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID)
return scanExecution(row)
}
@@ -576,12 +598,31 @@ func (s *DB) AppendTaskInteraction(taskID string, interaction task.Interaction)
// UpdateExecution updates a completed execution.
func (s *DB) UpdateExecution(e *Execution) error {
+ var changestatsJSON *string
+ if e.Changestats != nil {
+ b, err := json.Marshal(e.Changestats)
+ if err != nil {
+ return fmt.Errorf("marshaling changestats: %w", err)
+ }
+ s := string(b)
+ changestatsJSON = &s
+ }
+ commitsJSON := "[]"
+ if len(e.Commits) > 0 {
+ b, err := json.Marshal(e.Commits)
+ if err != nil {
+ return fmt.Errorf("marshaling commits: %w", err)
+ }
+ commitsJSON = string(b)
+ }
_, err := s.db.Exec(`
UPDATE executions SET end_time = ?, exit_code = ?, status = ?, cost_usd = ?, error_msg = ?,
- stdout_path = ?, stderr_path = ?, artifact_dir = ?, session_id = ?, sandbox_dir = ?
+ stdout_path = ?, stderr_path = ?, artifact_dir = ?, session_id = ?, sandbox_dir = ?,
+ changestats_json = ?, commits_json = ?
WHERE id = ?`,
e.EndTime.UTC(), e.ExitCode, e.Status, e.CostUSD, e.ErrorMsg,
- e.StdoutPath, e.StderrPath, e.ArtifactDir, e.SessionID, e.SandboxDir, e.ID,
+ e.StdoutPath, e.StderrPath, e.ArtifactDir, e.SessionID, e.SandboxDir,
+ changestatsJSON, commitsJSON, e.ID,
)
return err
}
@@ -647,16 +688,42 @@ func scanExecution(row scanner) (*Execution, error) {
var e Execution
var sessionID sql.NullString
var sandboxDir sql.NullString
+ var changestatsJSON sql.NullString
+ var commitsJSON sql.NullString
err := row.Scan(&e.ID, &e.TaskID, &e.StartTime, &e.EndTime, &e.ExitCode, &e.Status,
- &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir)
+ &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON)
if err != nil {
return nil, err
}
e.SessionID = sessionID.String
e.SandboxDir = sandboxDir.String
+ if changestatsJSON.Valid && changestatsJSON.String != "" {
+ var cs task.Changestats
+ if err := json.Unmarshal([]byte(changestatsJSON.String), &cs); err != nil {
+ return nil, fmt.Errorf("unmarshaling changestats: %w", err)
+ }
+ e.Changestats = &cs
+ }
+ if commitsJSON.Valid && commitsJSON.String != "" {
+ if err := json.Unmarshal([]byte(commitsJSON.String), &e.Commits); err != nil {
+ return nil, fmt.Errorf("unmarshaling commits: %w", err)
+ }
+ } else {
+ e.Commits = []task.GitCommit{}
+ }
return &e, nil
}
+// UpdateExecutionChangestats stores git change metrics for a completed execution.
+func (s *DB) UpdateExecutionChangestats(execID string, stats *task.Changestats) error {
+ b, err := json.Marshal(stats)
+ if err != nil {
+ return fmt.Errorf("marshaling changestats: %w", err)
+ }
+ _, err = s.db.Exec(`UPDATE executions SET changestats_json = ? WHERE id = ?`, string(b), execID)
+ return err
+}
+
func scanExecutionRows(rows *sql.Rows) (*Execution, error) {
return scanExecution(rows)
}
diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go
index 31be246..a16311d 100644
--- a/internal/storage/db_test.go
+++ b/internal/storage/db_test.go
@@ -893,3 +893,45 @@ func TestAppendTaskInteraction_NotFound(t *testing.T) {
}
}
+func TestExecution_StoreAndRetrieveChangestats(t *testing.T) {
+ db := testDB(t)
+ now := time.Now().UTC().Truncate(time.Second)
+ db.CreateTask(makeTestTask("cs-task", now))
+
+ exec := &Execution{
+ ID: "cs-exec",
+ TaskID: "cs-task",
+ StartTime: now,
+ Status: "COMPLETED",
+ }
+ if err := db.CreateExecution(exec); err != nil {
+ t.Fatalf("CreateExecution: %v", err)
+ }
+
+ stats := &task.Changestats{
+ FilesChanged: 5,
+ LinesAdded: 127,
+ LinesRemoved: 43,
+ }
+ if err := db.UpdateExecutionChangestats("cs-exec", stats); err != nil {
+ t.Fatalf("UpdateExecutionChangestats: %v", err)
+ }
+
+ got, err := db.GetExecution("cs-exec")
+ if err != nil {
+ t.Fatalf("GetExecution: %v", err)
+ }
+ if got.Changestats == nil {
+ t.Fatal("Changestats: want non-nil, got nil")
+ }
+ if got.Changestats.FilesChanged != 5 {
+ t.Errorf("FilesChanged: want 5, got %d", got.Changestats.FilesChanged)
+ }
+ if got.Changestats.LinesAdded != 127 {
+ t.Errorf("LinesAdded: want 127, got %d", got.Changestats.LinesAdded)
+ }
+ if got.Changestats.LinesRemoved != 43 {
+ t.Errorf("LinesRemoved: want 43, got %d", got.Changestats.LinesRemoved)
+ }
+}
+
diff --git a/internal/task/changestats.go b/internal/task/changestats.go
new file mode 100644
index 0000000..95be8ec
--- /dev/null
+++ b/internal/task/changestats.go
@@ -0,0 +1,47 @@
+package task
+
+import (
+ "bufio"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// gitDiffStatRe matches git diff --stat summary lines, e.g.:
+//
+// "3 files changed, 50 insertions(+), 10 deletions(-)"
+// "1 file changed, 5 insertions(+)"
+// "1 file changed, 3 deletions(-)"
+var gitDiffStatRe = regexp.MustCompile(`(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`)
+
+// ParseChangestatFromOutput scans text for git diff --stat summary lines and
+// returns the first match as a Changestats value. Returns nil if no match found.
+func ParseChangestatFromOutput(output string) *Changestats {
+ scanner := bufio.NewScanner(strings.NewReader(output))
+ for scanner.Scan() {
+ line := scanner.Text()
+ if m := gitDiffStatRe.FindStringSubmatch(line); m != nil {
+ cs := &Changestats{}
+ cs.FilesChanged, _ = strconv.Atoi(m[1])
+ if m[2] != "" {
+ cs.LinesAdded, _ = strconv.Atoi(m[2])
+ }
+ if m[3] != "" {
+ cs.LinesRemoved, _ = strconv.Atoi(m[3])
+ }
+ return cs
+ }
+ }
+ return nil
+}
+
+// ParseChangestatFromFile reads a log file and extracts the first git diff stat
+// summary it finds. Returns nil if the file cannot be read or contains no match.
+func ParseChangestatFromFile(path string) *Changestats {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ return ParseChangestatFromOutput(string(data))
+}
diff --git a/internal/task/task.go b/internal/task/task.go
index 69da5f3..6a9d1db 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -48,6 +48,19 @@ type RetryConfig struct {
Backoff string `yaml:"backoff" json:"backoff"` // "linear", "exponential"
}
+// GitCommit represents a single git commit created during a task execution.
+type GitCommit struct {
+ Hash string `json:"hash"`
+ Message string `json:"message"`
+}
+
+// Changestats records file/line change metrics from an agent execution.
+type Changestats struct {
+ FilesChanged int `json:"files_changed"`
+ LinesAdded int `json:"lines_added"`
+ LinesRemoved int `json:"lines_removed"`
+}
+
// Interaction records a single question/answer exchange between an agent and the user.
type Interaction struct {
QuestionText string `json:"question_text"`
diff --git a/scripts/next-task b/scripts/next-task
index e74ca26..9df09f0 100755
--- a/scripts/next-task
+++ b/scripts/next-task
@@ -11,7 +11,7 @@
# Usage: next_id=$(scripts/next-task)
# Example: scripts/start-next-task
-DB_PATH="/site/doot.terst.org/data/claudomator.db"
+DB_PATH="${DB_PATH:-/site/doot.terst.org/data/claudomator.db}"
# 1. Fetch the most recently updated COMPLETED or READY task
target=$(sqlite3 "$DB_PATH" "SELECT id, state, parent_task_id FROM tasks WHERE state IN ('COMPLETED', 'READY') ORDER BY updated_at DESC LIMIT 1;")
@@ -32,7 +32,7 @@ fi
if [ -z "$next_task" ]; then
# 4. No child/sibling found: fall back to highest-priority oldest PENDING task
- next_task=$(sqlite3 "$DB_PATH" "SELECT id FROM tasks WHERE state = 'PENDING' AND id != '$id'
+ FALLBACK_SQL="SELECT id FROM tasks WHERE state IN ('PENDING', 'QUEUED') AND id != '$id'
ORDER BY
CASE priority
WHEN 'critical' THEN 4
@@ -42,7 +42,8 @@ if [ -z "$next_task" ]; then
ELSE 0
END DESC,
created_at ASC
- LIMIT 1;")
+ LIMIT 1;"
+ next_task=$(sqlite3 "$DB_PATH" "$FALLBACK_SQL")
fi
echo "$next_task"
diff --git a/test/next-task.test.sh b/test/next-task.test.sh
new file mode 100644
index 0000000..3304efa
--- /dev/null
+++ b/test/next-task.test.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+# test/next-task.test.sh
+
+set -euo pipefail
+
+# Create a temporary database
+TEST_DB=$(mktemp)
+sqlite3 "$TEST_DB" <<EOF
+CREATE TABLE tasks (
+ id TEXT PRIMARY KEY,
+ state TEXT NOT NULL,
+ parent_task_id TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ priority TEXT DEFAULT 'normal'
+);
+EOF
+
+# Insert a COMPLETED task that will not have a PENDING child or sibling,
+# to ensure the fallback logic is triggered.
+sqlite3 "$TEST_DB" "INSERT INTO tasks (id, state, created_at) VALUES ('completed-no-children', 'COMPLETED', '2023-01-01 12:00:00');"
+
+# Inject a QUEUED task (should be picked by fallback)
+sqlite3 "$TEST_DB" "INSERT INTO tasks (id, state, priority, created_at) VALUES ('queued-task-id', 'QUEUED', 'high', '2023-01-01 10:00:00');"
+
+# Inject a PENDING task (lower priority, should not be picked first by fallback)
+sqlite3 "$TEST_DB" "INSERT INTO tasks (id, state, priority, created_at) VALUES ('pending-task-id', 'PENDING', 'normal', '2023-01-01 11:00:00');"
+
+# Run the next-task script with the temporary database path
+export DB_PATH="$TEST_DB" # Override DB_PATH for the test
+SCRIPT_DIR="$(dirname "$(dirname "$0")")/scripts"
+NEXT_TASK_ID=$("$SCRIPT_DIR/next-task")
+
+# Assert that the QUEUED task is returned
+if [[ "$NEXT_TASK_ID" == "queued-task-id" ]]; then
+ echo "Test passed: QUEUED task was selected by fallback."
+else
+ echo "Test failed: Expected 'queued-task-id', got '$NEXT_TASK_ID'"
+ exit 1
+fi
+
+# Clean up
+rm "$TEST_DB"
diff --git a/web/app.js b/web/app.js
index a7c18b6..77a2d9d 100644
--- a/web/app.js
+++ b/web/app.js
@@ -74,6 +74,24 @@ function formatDate(iso) {
});
}
+// Returns formatted string for changestats, e.g. "5 files, +127 -43".
+// Returns empty string for null/undefined input.
+export function formatChangestats(stats) {
+ if (stats == null) return '';
+ return `${stats.files_changed} files, +${stats.lines_added} -${stats.lines_removed}`;
+}
+
+// Returns a <span class="changestats-badge"> element for the given stats,
+// or null if stats is null/undefined.
+// Accepts an optional doc parameter for testability (defaults to document).
+export function renderChangestatsBadge(stats, doc = (typeof document !== 'undefined' ? document : null)) {
+ if (stats == null || doc == null) return null;
+ const span = doc.createElement('span');
+ span.className = 'changestats-badge';
+ span.textContent = formatChangestats(stats);
+ return span;
+}
+
function createTaskCard(task) {
const card = document.createElement('div');
card.className = 'task-card';
@@ -118,6 +136,13 @@ function createTaskCard(task) {
card.appendChild(desc);
}
+ // Changestats badge for COMPLETED/READY tasks
+ const CHANGESTATS_STATES = new Set(['COMPLETED', 'READY']);
+ if (CHANGESTATS_STATES.has(task.state) && task.changestats != null) {
+ const csBadge = renderChangestatsBadge(task.changestats);
+ if (csBadge) card.appendChild(csBadge);
+ }
+
// Footer: action buttons based on state
// Interrupted states (CANCELLED, FAILED, BUDGET_EXCEEDED) show both Resume and Restart.
// TIMED_OUT shows Resume only. Others show a single action.
@@ -1690,6 +1715,33 @@ function renderTaskPanel(task, executions) {
exitEl.textContent = `exit: ${exec.ExitCode ?? '—'}`;
row.appendChild(exitEl);
+ if (exec.Changestats != null) {
+ const csBadge = renderChangestatsBadge(exec.Changestats);
+ if (csBadge) row.appendChild(csBadge);
+ }
+
+ if (exec.Commits && exec.Commits.length > 0) {
+ const commitList = document.createElement('div');
+ commitList.className = 'execution-commits';
+ for (const commit of exec.Commits) {
+ const item = document.createElement('div');
+ item.className = 'commit-item';
+
+ const hash = document.createElement('span');
+ hash.className = 'commit-hash';
+ hash.textContent = commit.hash.slice(0, 7);
+ item.appendChild(hash);
+
+ const msg = document.createElement('span');
+ msg.className = 'commit-msg';
+ msg.textContent = commit.message;
+ item.appendChild(msg);
+
+ commitList.appendChild(item);
+ }
+ row.appendChild(commitList);
+ }
+
const logsBtn = document.createElement('button');
logsBtn.className = 'btn-view-logs';
logsBtn.textContent = 'View Logs';
diff --git a/web/style.css b/web/style.css
index 31f929e..e7d1de4 100644
--- a/web/style.css
+++ b/web/style.css
@@ -793,6 +793,39 @@ dialog label select:focus {
flex-wrap: wrap;
}
+.execution-commits {
+ width: 100%;
+ margin-top: 0.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ border-top: 1px solid var(--border-light);
+ padding-top: 0.5rem;
+}
+
+.commit-item {
+ display: flex;
+ gap: 0.5rem;
+ align-items: baseline;
+}
+
+.commit-hash {
+ font-family: var(--font-mono);
+ color: var(--text);
+ background: var(--bg-hover);
+ padding: 0.125rem 0.25rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+}
+
+.commit-msg {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
.execution-id {
font-family: monospace;
font-size: 0.72rem;
@@ -823,6 +856,13 @@ dialog label select:focus {
white-space: nowrap;
}
+.changestats-badge {
+ font-family: monospace;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+
.btn-view-logs {
font-size: 0.72rem;
font-weight: 600;
diff --git a/web/test/changestats.test.mjs b/web/test/changestats.test.mjs
new file mode 100644
index 0000000..5363812
--- /dev/null
+++ b/web/test/changestats.test.mjs
@@ -0,0 +1,125 @@
+// changestats.test.mjs — Unit tests for changestats display functions.
+//
+// Run with: node --test web/test/changestats.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { formatChangestats, renderChangestatsBadge } from '../app.js';
+
+// ── Mock DOM ───────────────────────────────────────────────────────────────────
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ const el = {
+ tag,
+ className: '',
+ textContent: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ return el;
+ },
+ };
+}
+
+// ── formatChangestats ──────────────────────────────────────────────────────────
+
+describe('formatChangestats', () => {
+ it('formats valid stats as "N files, +A -R"', () => {
+ const result = formatChangestats({ files_changed: 5, lines_added: 127, lines_removed: 43 });
+ assert.equal(result, '5 files, +127 -43');
+ });
+
+ it('returns empty string for null', () => {
+ const result = formatChangestats(null);
+ assert.equal(result, '');
+ });
+
+ it('returns empty string for undefined', () => {
+ const result = formatChangestats(undefined);
+ assert.equal(result, '');
+ });
+
+ it('formats zero values correctly', () => {
+ const result = formatChangestats({ files_changed: 0, lines_added: 0, lines_removed: 0 });
+ assert.equal(result, '0 files, +0 -0');
+ });
+
+ it('formats single file correctly', () => {
+ const result = formatChangestats({ files_changed: 1, lines_added: 10, lines_removed: 2 });
+ assert.equal(result, '1 files, +10 -2');
+ });
+});
+
+// ── renderChangestatsBadge ─────────────────────────────────────────────────────
+
+describe('renderChangestatsBadge', () => {
+ it('returns element with class changestats-badge for valid stats', () => {
+ const doc = makeDoc();
+ const el = renderChangestatsBadge({ files_changed: 5, lines_added: 127, lines_removed: 43 }, doc);
+ assert.ok(el, 'element should not be null');
+ assert.equal(el.className, 'changestats-badge');
+ });
+
+ it('returns element with correct text content', () => {
+ const doc = makeDoc();
+ const el = renderChangestatsBadge({ files_changed: 5, lines_added: 127, lines_removed: 43 }, doc);
+ assert.equal(el.textContent, '5 files, +127 -43');
+ });
+
+ it('returns null for null stats', () => {
+ const doc = makeDoc();
+ const el = renderChangestatsBadge(null, doc);
+ assert.equal(el, null);
+ });
+
+ it('returns null for undefined stats', () => {
+ const doc = makeDoc();
+ const el = renderChangestatsBadge(undefined, doc);
+ assert.equal(el, null);
+ });
+});
+
+// ── State-based visibility ────────────────────────────────────────────────────
+//
+// Changestats badge should appear on COMPLETED (and READY) tasks that have
+// changestats data, and must not appear on QUEUED tasks.
+
+const CHANGESTATS_STATES = new Set(['COMPLETED', 'READY']);
+
+function shouldShowChangestats(task) {
+ return CHANGESTATS_STATES.has(task.state) && task.changestats != null;
+}
+
+describe('changestats badge visibility by task state', () => {
+ it('COMPLETED task with changestats shows badge', () => {
+ const task = { state: 'COMPLETED', changestats: { files_changed: 3, lines_added: 50, lines_removed: 10 } };
+ assert.equal(shouldShowChangestats(task), true);
+ });
+
+ it('READY task with changestats shows badge', () => {
+ const task = { state: 'READY', changestats: { files_changed: 1, lines_added: 5, lines_removed: 2 } };
+ assert.equal(shouldShowChangestats(task), true);
+ });
+
+ it('QUEUED task hides changestats', () => {
+ const task = { state: 'QUEUED', changestats: { files_changed: 3, lines_added: 50, lines_removed: 10 } };
+ assert.equal(shouldShowChangestats(task), false);
+ });
+
+ it('COMPLETED task without changestats hides badge', () => {
+ const task = { state: 'COMPLETED', changestats: null };
+ assert.equal(shouldShowChangestats(task), false);
+ });
+
+ it('RUNNING task hides changestats', () => {
+ const task = { state: 'RUNNING', changestats: null };
+ assert.equal(shouldShowChangestats(task), false);
+ });
+
+ it('PENDING task hides changestats', () => {
+ const task = { state: 'PENDING', changestats: null };
+ assert.equal(shouldShowChangestats(task), false);
+ });
+});