summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
13 files changed, 639 insertions, 47 deletions
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"`