summaryrefslogtreecommitdiff
path: root/internal/executor
diff options
context:
space:
mode:
authorClaude Sonnet 4.6 <noreply@anthropic.com>2026-03-14 18:11:44 +0000
committerClaude Sonnet 4.6 <noreply@anthropic.com>2026-03-14 18:11:44 +0000
commit43440200facf9f7c51ba4f4638e69e7d651dd50d (patch)
tree5984798cb6ba0038e17c0e175ab35054276b38cf /internal/executor
parent5ad88bfc08584585b56ac8eee9cde95ed20b7b43 (diff)
feat(Phase4): add file changes for changestats executor wiring
Files changed: CLAUDE.md, internal/api/changestats.go, internal/executor/executor.go, internal/executor/executor_test.go, internal/task/changestats.go (new) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/executor')
-rw-r--r--internal/executor/executor.go8
-rw-r--r--internal/executor/executor_test.go154
2 files changed, 159 insertions, 3 deletions
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 7674fe6..fd37c33 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,13 @@ 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 {
+ 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)
+ }
+}