diff options
| author | Claudomator Agent <agent@claudomator> | 2026-06-05 09:21:32 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator> | 2026-06-05 09:21:32 +0000 |
| commit | 0eb0b79396663c4901597becc6857a4cd795c58e (patch) | |
| tree | 3532c82aa3f7c6deeed03f143d1c360d7cf6cc47 /internal | |
| parent | fa3ed5220a28f326d443726233c37e1a7df0ced5 (diff) | |
chore: remove stories/checker backend dead code
Remove the dead stories/checker backend tracked as design debt in CLAUDE.md:
- Delete internal/api/stories.go, stories_test.go, elaborate.go
- Delete internal/task/story.go, story_test.go
- Remove story/checker columns from storage schema and all CRUD methods
- Remove checkStoryCompletion, spawnCheckerTask, triggerStoryDeploy,
createValidationTask, checkValidationResult, ShipStory, CheckStoryCompletion
from executor; simplify handleRunResult
- Remove story/checker fields from task.Task (StoryID, AcceptanceCriteria,
CheckerForTaskID, CheckerReport)
- Remove story routes and geminiBinPath from API server
- Move claudeJSONResult/extractJSON from elaborate.go to validate.go
- Rename ensureStoryBranch -> ensureBranch in ContainerRunner
- Fix git identity in bare-repo tests; fix initial-branch to use main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/agentmcp_endpoint_test.go | 4 | ||||
| -rw-r--r-- | internal/api/elaborate.go | 244 | ||||
| -rw-r--r-- | internal/api/server.go | 15 | ||||
| -rw-r--r-- | internal/api/server_test.go | 51 | ||||
| -rw-r--r-- | internal/api/stories.go | 378 | ||||
| -rw-r--r-- | internal/api/stories_test.go | 351 | ||||
| -rw-r--r-- | internal/api/taskops.go | 3 | ||||
| -rw-r--r-- | internal/api/validate.go | 18 | ||||
| -rw-r--r-- | internal/api/webhook_test.go | 16 | ||||
| -rw-r--r-- | internal/cli/serve.go | 2 | ||||
| -rw-r--r-- | internal/executor/container.go | 73 | ||||
| -rw-r--r-- | internal/executor/container_test.go | 41 | ||||
| -rw-r--r-- | internal/executor/executor.go | 353 | ||||
| -rw-r--r-- | internal/executor/executor_test.go | 548 | ||||
| -rw-r--r-- | internal/storage/db.go | 129 | ||||
| -rw-r--r-- | internal/storage/db_test.go | 183 | ||||
| -rw-r--r-- | internal/task/story.go | 41 | ||||
| -rw-r--r-- | internal/task/story_test.go | 42 | ||||
| -rw-r--r-- | internal/task/task.go | 4 |
19 files changed, 99 insertions, 2397 deletions
diff --git a/internal/api/agentmcp_endpoint_test.go b/internal/api/agentmcp_endpoint_test.go index b2f77d3..b9cee3a 100644 --- a/internal/api/agentmcp_endpoint_test.go +++ b/internal/api/agentmcp_endpoint_test.go @@ -52,7 +52,7 @@ func TestServer_MountsAgentMCPEndpoint(t *testing.T) { fc := &fakeAgentChannel{} token, _ := reg.Mint(fc) - srv := NewServer(store, pool, reg, logger, "claude", "gemini") + srv := NewServer(store, pool, reg, logger, "claude") ts := httptest.NewServer(srv.Handler()) defer ts.Close() @@ -87,7 +87,7 @@ func TestServer_NoRegistry_NoMCPRoute(t *testing.T) { pool := executor.NewPool(1, map[string]executor.Runner{}, store, logger) // nil registry → /mcp must not be mounted. - srv := NewServer(store, pool, nil, logger, "claude", "gemini") + srv := NewServer(store, pool, nil, logger, "claude") ts := httptest.NewServer(srv.Handler()) defer ts.Close() diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go deleted file mode 100644 index e8930a6..0000000 --- a/internal/api/elaborate.go +++ /dev/null @@ -1,244 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "os/exec" - "strings" - "time" -) - -const elaborateTimeout = 30 * time.Second - -// claudeJSONResult is the top-level object returned by `claude --output-format json`. -type claudeJSONResult struct { - Result string `json:"result"` - IsError bool `json:"is_error"` -} - -// geminiJSONResult is the top-level object returned by `gemini --output-format json`. -type geminiJSONResult struct { - Response string `json:"response"` -} - -// extractJSON returns the first top-level JSON object found in s, stripping -// surrounding prose or markdown code fences the model may have added. -func extractJSON(s string) string { - start := strings.Index(s, "{") - end := strings.LastIndex(s, "}") - if start == -1 || end == -1 || end < start { - return s - } - return s[start : end+1] -} - -func (s *Server) claudeBinaryPath() string { - if s.elaborateCmdPath != "" { - return s.elaborateCmdPath - } - if s.claudeBinPath != "" { - return s.claudeBinPath - } - return "claude" -} - -func (s *Server) geminiBinaryPath() string { - if s.geminiBinPath != "" { - return s.geminiBinPath - } - return "gemini" -} - -// elaboratedStorySubtask is a leaf unit within a story task. -type elaboratedStorySubtask struct { - Name string `json:"name"` - Instructions string `json:"instructions"` -} - -// elaboratedStoryTask is one independently-deployable unit in a story plan. -type elaboratedStoryTask struct { - Name string `json:"name"` - Instructions string `json:"instructions"` - AcceptanceCriteria string `json:"acceptance_criteria"` - Subtasks []elaboratedStorySubtask `json:"subtasks"` -} - -// elaboratedStoryValidation describes how to verify the story was successful. -type elaboratedStoryValidation struct { - Type string `json:"type"` - Steps []string `json:"steps"` - SuccessCriteria string `json:"success_criteria"` -} - -// elaboratedStory is the full implementation plan produced by story elaboration. -type elaboratedStory struct { - Name string `json:"name"` - BranchName string `json:"branch_name"` - Tasks []elaboratedStoryTask `json:"tasks"` - Validation elaboratedStoryValidation `json:"validation"` -} - -func buildStoryElaboratePrompt() string { - return `You are a software architect. Given a goal, analyze the codebase at /workspace and produce a structured implementation plan as JSON. - -Output ONLY valid JSON matching this schema: -{ - "name": "story name", - "branch_name": "story/kebab-case-name", - "tasks": [ - { - "name": "task name", - "instructions": "detailed instructions including file paths and what to change", - "acceptance_criteria": "specific, verifiable conditions a separate reviewer can check — e.g. 'run go test ./... and verify all pass; confirm GET /api/foo returns 200 with expected JSON shape'", - "subtasks": [ - { "name": "subtask name", "instructions": "..." } - ] - } - ], - "validation": { - "type": "build|test|smoke", - "steps": ["step1", "step2"], - "success_criteria": "what success looks like" - } -} - -Rules: -- Tasks must be independently buildable (each can be deployed alone) -- Subtasks within a task are order-dependent and run sequentially -- Instructions must include specific file paths, function names, and exact changes -- Instructions must end with: git add -A && git commit -m "..." && git push origin <branch> -- Validation should match the scope: small change = build check; new feature = smoke test -- acceptance_criteria must be concrete and verifiable by a separate agent — no vague assertions like "code looks good"` -} - -func (s *Server) elaborateStoryWithClaude(ctx context.Context, workDir, goal string) (*elaboratedStory, error) { - cmd := exec.CommandContext(ctx, s.claudeBinaryPath(), - "-p", goal, - "--system-prompt", buildStoryElaboratePrompt(), - "--output-format", "json", - "--model", "haiku", - ) - if workDir != "" { - cmd.Dir = workDir - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - - output := stdout.Bytes() - if len(output) == 0 { - if err != nil { - return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String()) - } - return nil, fmt.Errorf("claude returned no output") - } - - var wrapper claudeJSONResult - if jerr := json.Unmarshal(output, &wrapper); jerr != nil { - return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output)) - } - if wrapper.IsError { - return nil, fmt.Errorf("claude error: %s", wrapper.Result) - } - - var result elaboratedStory - if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil { - return nil, fmt.Errorf("failed to parse elaborated story JSON: %w (result: %s)", jerr, wrapper.Result) - } - return &result, nil -} - -func (s *Server) elaborateStoryWithGemini(ctx context.Context, workDir, goal string) (*elaboratedStory, error) { - combinedPrompt := fmt.Sprintf("%s\n\n%s", buildStoryElaboratePrompt(), goal) - cmd := exec.CommandContext(ctx, s.geminiBinaryPath(), - "-p", combinedPrompt, - "--output-format", "json", - "--model", "gemini-2.5-flash-lite", - ) - if workDir != "" { - cmd.Dir = workDir - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String()) - } - - var wrapper geminiJSONResult - if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil { - return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String()) - } - - var result elaboratedStory - if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil { - return nil, fmt.Errorf("failed to parse elaborated story JSON: %w (response: %s)", err, wrapper.Response) - } - return &result, nil -} - -func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) { - var input struct { - Goal string `json:"goal"` - ProjectID string `json:"project_id"` - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) - return - } - if input.Goal == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "goal is required"}) - return - } - if input.ProjectID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id is required"}) - return - } - - proj, err := s.store.GetProject(input.ProjectID) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) - return - } - - // Update git refs without modifying the working tree. - if proj.LocalPath != "" { - gitCmd := exec.Command("git", "-C", proj.LocalPath, "fetch", "origin") - if err := gitCmd.Run(); err != nil { - s.logger.Warn("story elaborate: git fetch failed", "error", err, "path", proj.LocalPath) - } - } - - ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout) - defer cancel() - - result, err := s.elaborateStoryWithClaude(ctx, proj.LocalPath, input.Goal) - if err != nil { - s.logger.Warn("story elaborate: claude failed, falling back to gemini", "error", err) - result, err = s.elaborateStoryWithGemini(ctx, proj.LocalPath, input.Goal) - if err != nil { - s.logger.Error("story elaborate: fallback gemini also failed", "error", err) - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": fmt.Sprintf("elaboration failed: %v", err), - }) - return - } - } - - if result.Name == "" { - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": "elaboration failed: missing required fields in response", - }) - return - } - - writeJSON(w, http.StatusOK, result) -} diff --git a/internal/api/server.go b/internal/api/server.go index 4af7325..35128d0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -46,8 +46,6 @@ type Server struct { logger *slog.Logger mux *http.ServeMux claudeBinPath string // path to claude binary; defaults to "claude" - geminiBinPath string // path to gemini binary; defaults to "gemini" - elaborateCmdPath string // overrides claudeBinPath; used in tests validateCmdPath string // overrides claudeBinPath for validate; used in tests scripts ScriptRegistry // optional; maps endpoint name → script path workDir string // working directory injected into elaborate system prompt @@ -113,7 +111,7 @@ func (s *Server) SetLLM(c *llm.Client) { } -func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Registry, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server { +func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Registry, logger *slog.Logger, claudeBinPath string) *Server { wd, _ := os.Getwd() s := &Server{ ctx: context.Background(), @@ -127,7 +125,6 @@ func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Regist logger: logger, mux: http.NewServeMux(), claudeBinPath: claudeBinPath, - geminiBinPath: geminiBinPath, workDir: wd, workspaceRoot: "/workspace", } @@ -175,16 +172,6 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/projects", s.handleCreateProject) s.mux.HandleFunc("GET /api/projects/{id}", s.handleGetProject) s.mux.HandleFunc("PUT /api/projects/{id}", s.handleUpdateProject) - s.mux.HandleFunc("POST /api/stories/elaborate", s.handleElaborateStory) - s.mux.HandleFunc("POST /api/stories/approve", s.handleApproveStory) - s.mux.HandleFunc("GET /api/stories", s.handleListStories) - s.mux.HandleFunc("POST /api/stories", s.handleCreateStory) - s.mux.HandleFunc("GET /api/stories/{id}", s.handleGetStory) - s.mux.HandleFunc("GET /api/stories/{id}/tasks", s.handleListStoryTasks) - s.mux.HandleFunc("POST /api/stories/{id}/tasks", s.handleAddTaskToStory) - s.mux.HandleFunc("PUT /api/stories/{id}/status", s.handleUpdateStoryStatus) - s.mux.HandleFunc("POST /api/stories/{id}/ship", s.handleShipStory) - s.mux.HandleFunc("GET /api/stories/{id}/deployment-status", s.handleStoryDeploymentStatus) s.mux.HandleFunc("GET /api/health", s.handleHealth) s.mux.HandleFunc("GET /api/version", s.handleVersion) s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 43d91b0..24e1d30 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -98,7 +98,7 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora "gemini": runner, } pool := executor.NewPool(2, runners, store, logger) - srv := NewServer(store, pool, nil, logger, "claude", "gemini") + srv := NewServer(store, pool, nil, logger, "claude") return srv, store } @@ -194,7 +194,7 @@ func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) { "gemini": mr, } pool := executor.NewPool(2, runners, store, logger) - srv := NewServer(store, pool, nil, logger, "claude", "gemini") + srv := NewServer(store, pool, nil, logger, "claude") return srv, store } @@ -2069,50 +2069,3 @@ func TestHandleRunTask_CascadesRetryToFailedDeps(t *testing.T) { } } -func TestShipStory_ShippableStory_Returns202(t *testing.T) { - srv, store := testServer(t) - - proj := &task.Project{ - ID: "ship-proj-1", Name: "test", RemoteURL: "https://github.com/x/y", - Type: "web", DeployScript: "", - } - if err := store.CreateProject(proj); err != nil { - t.Fatalf("CreateProject: %v", err) - } - - story := &task.Story{ - ID: "ship-story-1", Name: "Ship Test", ProjectID: "ship-proj-1", - Status: task.StoryShippable, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - req := httptest.NewRequest("POST", "/api/stories/ship-story-1/ship", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusAccepted { - t.Errorf("expected 202, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestShipStory_NonShippable_Returns409(t *testing.T) { - srv, store := testServer(t) - - story := &task.Story{ - ID: "nonship-1", Name: "Not Ready", ProjectID: "", - Status: task.StoryInProgress, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - req := httptest.NewRequest("POST", "/api/stories/nonship-1/ship", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusConflict { - t.Errorf("expected 409, got %d", w.Code) - } -} diff --git a/internal/api/stories.go b/internal/api/stories.go deleted file mode 100644 index fa10ccd..0000000 --- a/internal/api/stories.go +++ /dev/null @@ -1,378 +0,0 @@ -package api - -import ( - "database/sql" - "encoding/json" - "fmt" - "net/http" - "os/exec" - "strings" - "time" - - "github.com/google/uuid" - "github.com/thepeterstone/claudomator/internal/deployment" - "github.com/thepeterstone/claudomator/internal/task" -) - -// createStoryBranch creates a new git branch in localPath from the latest main -// and pushes it to remoteURL (the bare repo). Idempotent: treats "already exists" as success. -func createStoryBranch(localPath, branchName, remoteURL string) error { - // Fetch latest from the bare repo so we have an up-to-date base. - if out, err := exec.Command("git", "-C", localPath, "fetch", remoteURL, "main").CombinedOutput(); err != nil { - return fmt.Errorf("git fetch: %w (output: %s)", err, string(out)) - } - base := "FETCH_HEAD" - out, err := exec.Command("git", "-C", localPath, "checkout", "-b", branchName, base).CombinedOutput() - if err != nil { - if !strings.Contains(string(out), "already exists") { - return fmt.Errorf("git checkout -b: %w (output: %s)", err, string(out)) - } - } - if out, err := exec.Command("git", "-C", localPath, "push", remoteURL, branchName).CombinedOutput(); err != nil { - return fmt.Errorf("git push: %w (output: %s)", err, string(out)) - } - return nil -} - -func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) { - stories, err := s.store.ListStories() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if stories == nil { - stories = []*task.Story{} - } - writeJSON(w, http.StatusOK, stories) -} - -func (s *Server) handleCreateStory(w http.ResponseWriter, r *http.Request) { - var st task.Story - if err := json.NewDecoder(r.Body).Decode(&st); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) - return - } - if st.Name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) - return - } - if st.ID == "" { - st.ID = uuid.New().String() - } - if st.Status == "" { - st.Status = task.StoryPending - } - if err := s.store.CreateStory(&st); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusCreated, st) -} - -func (s *Server) handleGetStory(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - st, err := s.store.GetStory(id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) - return - } - writeJSON(w, http.StatusOK, st) -} - -func (s *Server) handleListStoryTasks(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if _, err := s.store.GetStory(id); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) - return - } - tasks, err := s.store.ListTasksByStory(id) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if tasks == nil { - tasks = []*task.Task{} - } - writeJSON(w, http.StatusOK, tasks) -} - -func (s *Server) handleAddTaskToStory(w http.ResponseWriter, r *http.Request) { - storyID := r.PathValue("id") - st, err := s.store.GetStory(storyID) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) - return - } - _ = st - - var input struct { - Name string `json:"name"` - Description string `json:"description"` - Project string `json:"project"` - RepositoryURL string `json:"repository_url"` - Agent task.AgentConfig `json:"agent"` - Claude task.AgentConfig `json:"claude"` - Timeout string `json:"timeout"` - Priority string `json:"priority"` - Tags []string `json:"tags"` - ParentTaskID string `json:"parent_task_id"` - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) - return - } - if input.Agent.Instructions == "" && input.Claude.Instructions != "" { - input.Agent = input.Claude - } - - existing, err := s.store.ListTasksByStory(storyID) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - now := time.Now().UTC() - t := &task.Task{ - ID: uuid.New().String(), - Name: input.Name, - Description: input.Description, - Project: input.Project, - RepositoryURL: input.RepositoryURL, - Agent: input.Agent, - Priority: task.Priority(input.Priority), - Tags: input.Tags, - DependsOn: []string{}, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, - State: task.StatePending, - StoryID: storyID, - ParentTaskID: input.ParentTaskID, - CreatedAt: now, - UpdatedAt: now, - } - - if t.Agent.Type == "" { - t.Agent.Type = "claude" - } - if t.Priority == "" { - t.Priority = task.PriorityNormal - } - if t.Tags == nil { - t.Tags = []string{} - } - if input.Timeout != "" { - dur, err := time.ParseDuration(input.Timeout) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid timeout: " + err.Error()}) - return - } - t.Timeout.Duration = dur - } - - // Auto-wire depends_on: new task depends on the last existing task (sorted ASC by created_at). - if len(existing) > 0 { - lastTask := existing[len(existing)-1] - t.DependsOn = []string{lastTask.ID} - } - - if err := s.store.CreateTask(t); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusCreated, t) -} - -func (s *Server) handleUpdateStoryStatus(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - st, err := s.store.GetStory(id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) - return - } - - var input struct { - Status task.StoryState `json:"status"` - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) - return - } - if !task.ValidStoryTransition(st.Status, input.Status) { - writeJSON(w, http.StatusConflict, map[string]string{ - "error": "invalid story status transition from " + string(st.Status) + " to " + string(input.Status), - }) - return - } - if err := s.store.UpdateStoryStatus(id, input.Status); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"message": "story status updated", "story_id": id, "status": string(input.Status)}) -} - -func (s *Server) handleApproveStory(w http.ResponseWriter, r *http.Request) { - var input struct { - Name string `json:"name"` - BranchName string `json:"branch_name"` - ProjectID string `json:"project_id"` - Tasks []elaboratedStoryTask `json:"tasks"` - Validation elaboratedStoryValidation `json:"validation"` - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) - return - } - if input.Name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) - return - } - - validationJSON, _ := json.Marshal(input.Validation) - now := time.Now().UTC() - story := &task.Story{ - ID: uuid.New().String(), - Name: input.Name, - ProjectID: input.ProjectID, - BranchName: input.BranchName, - ValidationJSON: string(validationJSON), - Status: task.StoryPending, - CreatedAt: now, - UpdatedAt: now, - } - if err := s.store.CreateStory(story); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - var repoURL string - if input.ProjectID != "" { - if proj, err := s.store.GetProject(input.ProjectID); err == nil { - repoURL = proj.RemoteURL - } - } - - taskIDs := make([]string, 0, len(input.Tasks)) - var prevTaskID string - for _, tp := range input.Tasks { - t := &task.Task{ - ID: uuid.New().String(), - Name: tp.Name, - Project: input.ProjectID, - RepositoryURL: repoURL, - StoryID: story.ID, - Agent: task.AgentConfig{Type: "claude", Instructions: tp.Instructions}, - AcceptanceCriteria: tp.AcceptanceCriteria, - Priority: task.PriorityNormal, - Tags: []string{}, - DependsOn: []string{}, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, - State: task.StatePending, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - if prevTaskID != "" { - t.DependsOn = []string{prevTaskID} - } - if err := s.store.CreateTask(t); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - taskIDs = append(taskIDs, t.ID) - - var prevSubtaskID string - for _, sub := range tp.Subtasks { - st := &task.Task{ - ID: uuid.New().String(), - Name: sub.Name, - Project: input.ProjectID, - RepositoryURL: repoURL, - StoryID: story.ID, - ParentTaskID: t.ID, - Agent: task.AgentConfig{Type: "claude", Instructions: sub.Instructions}, - Priority: task.PriorityNormal, - Tags: []string{}, - DependsOn: []string{}, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, - State: task.StatePending, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - if prevSubtaskID != "" { - st.DependsOn = []string{prevSubtaskID} - } - if err := s.store.CreateTask(st); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - prevSubtaskID = st.ID - } - prevTaskID = t.ID - } - - // Create the story branch (non-fatal if it fails). - if input.BranchName != "" && input.ProjectID != "" { - if proj, err := s.store.GetProject(input.ProjectID); err == nil && proj.LocalPath != "" && proj.RemoteURL != "" { - if err := createStoryBranch(proj.LocalPath, input.BranchName, proj.RemoteURL); err != nil { - s.logger.Warn("story approve: failed to create branch", "error", err, "branch", input.BranchName) - } - } - } - - writeJSON(w, http.StatusCreated, map[string]interface{}{ - "story": story, - "task_ids": taskIDs, - }) -} - -// handleShipStory triggers the merge + deploy for a SHIPPABLE story. -// POST /api/stories/{id}/ship -func (s *Server) handleShipStory(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if err := s.pool.ShipStory(r.Context(), id); err != nil { - writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusAccepted, map[string]string{"message": "story shipping initiated", "story_id": id}) -} - -// handleStoryDeploymentStatus aggregates the deployment status across all tasks in a story. -// GET /api/stories/{id}/deployment-status -func (s *Server) handleStoryDeploymentStatus(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - - story, err := s.store.GetStory(id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) - return - } - - tasks, err := s.store.ListTasksByStory(id) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - // Collect all commits from the latest execution of each task. - var allCommits []task.GitCommit - for _, t := range tasks { - exec, err := s.store.GetLatestExecution(t.ID) - if err != nil { - if err == sql.ErrNoRows { - continue - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - allCommits = append(allCommits, exec.Commits...) - } - - // Determine project remote URL for the deployment check. - projectRemoteURL := "" - if story.ProjectID != "" { - if proj, err := s.store.GetProject(story.ProjectID); err == nil { - projectRemoteURL = proj.RemoteURL - } - } - - status := deployment.Check(allCommits, projectRemoteURL) - writeJSON(w, http.StatusOK, status) -} diff --git a/internal/api/stories_test.go b/internal/api/stories_test.go deleted file mode 100644 index f43ad86..0000000 --- a/internal/api/stories_test.go +++ /dev/null @@ -1,351 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/thepeterstone/claudomator/internal/deployment" - "github.com/thepeterstone/claudomator/internal/task" -) - -func TestCreateStory_API(t *testing.T) { - srv, _ := testServer(t) - - body := `{"name":"My Story","project_id":"proj-1"}` - req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) - } - var st task.Story - if err := json.NewDecoder(w.Body).Decode(&st); err != nil { - t.Fatalf("decode response: %v", err) - } - if st.Name != "My Story" { - t.Errorf("Name: want 'My Story', got %q", st.Name) - } - if st.ID == "" { - t.Error("ID should be auto-generated") - } - if st.Status != task.StoryPending { - t.Errorf("Status: want PENDING, got %q", st.Status) - } -} - -func TestGetStory_API(t *testing.T) { - srv, _ := testServer(t) - - // Create a story first. - body := `{"name":"Get Me"}` - req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - if w.Code != http.StatusCreated { - t.Fatalf("create story: expected 201, got %d", w.Code) - } - var created task.Story - json.NewDecoder(w.Body).Decode(&created) - - // Fetch it. - req2 := httptest.NewRequest("GET", "/api/stories/"+created.ID, nil) - w2 := httptest.NewRecorder() - srv.mux.ServeHTTP(w2, req2) - - if w2.Code != http.StatusOK { - t.Fatalf("get story: expected 200, got %d: %s", w2.Code, w2.Body.String()) - } - var got task.Story - if err := json.NewDecoder(w2.Body).Decode(&got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.ID != created.ID { - t.Errorf("ID: want %q, got %q", created.ID, got.ID) - } - if got.Name != "Get Me" { - t.Errorf("Name: want 'Get Me', got %q", got.Name) - } -} - -func TestAddTaskToStory_AutoWiresDependsOn(t *testing.T) { - srv, _ := testServer(t) - - // Create a story. - storyBody := `{"name":"Story For Tasks"}` - req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(storyBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - if w.Code != http.StatusCreated { - t.Fatalf("create story: %d %s", w.Code, w.Body.String()) - } - var story task.Story - json.NewDecoder(w.Body).Decode(&story) - - addTask := func(name string) *task.Task { - body := `{"name":"` + name + `","agent":{"type":"claude","instructions":"do it"}}` - r := httptest.NewRequest("POST", "/api/stories/"+story.ID+"/tasks", bytes.NewBufferString(body)) - r.Header.Set("Content-Type", "application/json") - wr := httptest.NewRecorder() - srv.mux.ServeHTTP(wr, r) - if wr.Code != http.StatusCreated { - t.Fatalf("add task %s: expected 201, got %d: %s", name, wr.Code, wr.Body.String()) - } - var tk task.Task - json.NewDecoder(wr.Body).Decode(&tk) - return &tk - } - - task1 := addTask("Task 1") - task2 := addTask("Task 2") - task3 := addTask("Task 3") - - // task1 has no dependencies. - if len(task1.DependsOn) != 0 { - t.Errorf("task1.DependsOn: want [], got %v", task1.DependsOn) - } - // task2 depends on task1. - if len(task2.DependsOn) != 1 || task2.DependsOn[0] != task1.ID { - t.Errorf("task2.DependsOn: want [%s], got %v", task1.ID, task2.DependsOn) - } - // task3 depends on task2. - if len(task3.DependsOn) != 1 || task3.DependsOn[0] != task2.ID { - t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn) - } -} - -func TestBuildStoryElaboratePrompt(t *testing.T) { - prompt := buildStoryElaboratePrompt() - checks := []struct { - label string - want string - }{ - {"schema: name field", `"name"`}, - {"schema: branch_name field", `"branch_name"`}, - {"schema: tasks field", `"tasks"`}, - {"schema: validation field", `"validation"`}, - {"rule: git push", "git push origin"}, - {"rule: sequential subtasks", "sequentially"}, - {"rule: specific file paths", "file paths"}, - } - for _, c := range checks { - if !strings.Contains(prompt, c.want) { - t.Errorf("%s: prompt should contain %q", c.label, c.want) - } - } -} - -func TestHandleStoryApprove_WiresDepends(t *testing.T) { - srv, _ := testServer(t) - - body := `{ - "name": "My Story", - "branch_name": "story/my-story", - "tasks": [ - {"name": "Task 1", "instructions": "do task 1", "subtasks": []}, - {"name": "Task 2", "instructions": "do task 2", "subtasks": []}, - {"name": "Task 3", "instructions": "do task 3", "subtasks": []} - ], - "validation": {"type": "build", "steps": ["go build ./..."], "success_criteria": "compiles"} - }` - req := httptest.NewRequest("POST", "/api/stories/approve", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - Story task.Story `json:"story"` - TaskIDs []string `json:"task_ids"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(resp.TaskIDs) != 3 { - t.Fatalf("expected 3 task IDs, got %d", len(resp.TaskIDs)) - } - if resp.Story.Name != "My Story" { - t.Errorf("story name: want 'My Story', got %q", resp.Story.Name) - } - - // Verify depends_on chain via the store. - store := srv.store - task1, err := store.GetTask(resp.TaskIDs[0]) - if err != nil { - t.Fatalf("GetTask[0]: %v", err) - } - task2, err := store.GetTask(resp.TaskIDs[1]) - if err != nil { - t.Fatalf("GetTask[1]: %v", err) - } - task3, err := store.GetTask(resp.TaskIDs[2]) - if err != nil { - t.Fatalf("GetTask[2]: %v", err) - } - - if len(task1.DependsOn) != 0 { - t.Errorf("task1.DependsOn: want [], got %v", task1.DependsOn) - } - if len(task2.DependsOn) != 1 || task2.DependsOn[0] != task1.ID { - t.Errorf("task2.DependsOn: want [%s], got %v", task1.ID, task2.DependsOn) - } - if len(task3.DependsOn) != 1 || task3.DependsOn[0] != task2.ID { - t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn) - } -} - -func TestHandleStoryApprove_SetsRepositoryURL(t *testing.T) { - srv, store := testServer(t) - - proj := &task.Project{ - ID: "proj-repo", - Name: "claudomator", - RemoteURL: "/site/git.terst.org/repos/claudomator.git", - // LocalPath intentionally empty: branch creation is a non-fatal side effect, - // omitting it keeps the test fast and free of real git operations. - } - if err := store.CreateProject(proj); err != nil { - t.Fatalf("CreateProject: %v", err) - } - - body := `{ - "name": "Repo URL Story", - "branch_name": "story/repo-url", - "project_id": "proj-repo", - "tasks": [ - {"name": "Task A", "instructions": "do A", "subtasks": []}, - {"name": "Task B", "instructions": "do B", "subtasks": [ - {"name": "Sub B1", "instructions": "do B1"} - ]} - ], - "validation": {"type": "build", "steps": ["go build ./..."], "success_criteria": "ok"} - }` - req := httptest.NewRequest("POST", "/api/stories/approve", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - TaskIDs []string `json:"task_ids"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode: %v", err) - } - - for _, id := range resp.TaskIDs { - tk, err := store.GetTask(id) - if err != nil { - t.Fatalf("GetTask %s: %v", id, err) - } - if tk.RepositoryURL != proj.RemoteURL { - t.Errorf("task %s RepositoryURL: want %q, got %q", tk.ID, proj.RemoteURL, tk.RepositoryURL) - } - } -} - -func TestApproveStory_AcceptanceCriteriaStored(t *testing.T) { - srv, store := testServer(t) - - proj := &task.Project{ - ID: "ac-proj", Name: "test", RemoteURL: "https://github.com/x/y", - Type: "web", DeployScript: "", - } - store.CreateProject(proj) - - body := `{ - "name": "AC Story", - "branch_name": "story/ac-test", - "project_id": "ac-proj", - "tasks": [ - { - "name": "Add feature", - "instructions": "implement the thing", - "acceptance_criteria": "run go test ./... and verify all pass", - "subtasks": [] - } - ], - "validation": {"type": "test", "steps": [], "success_criteria": "tests pass"} - }` - req := httptest.NewRequest("POST", "/api/stories/approve", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - TaskIDs []string `json:"task_ids"` - } - json.NewDecoder(w.Body).Decode(&resp) - if len(resp.TaskIDs) == 0 { - t.Fatal("expected task_ids in response") - } - - tk, err := store.GetTask(resp.TaskIDs[0]) - if err != nil { - t.Fatalf("GetTask: %v", err) - } - if tk.AcceptanceCriteria != "run go test ./... and verify all pass" { - t.Errorf("expected acceptance criteria stored on task, got %q", tk.AcceptanceCriteria) - } -} - -func TestHandleStoryDeploymentStatus(t *testing.T) { - srv, store := testServer(t) - - // Create a story. - now := time.Now().UTC() - story := &task.Story{ - ID: "deploy-story-1", - Name: "Deploy Status Story", - Status: task.StoryInProgress, - CreatedAt: now, - UpdatedAt: now, - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - // Request deployment status — no tasks yet. - req := httptest.NewRequest("GET", "/api/stories/deploy-story-1/deployment-status", nil) - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var status deployment.Status - if err := json.NewDecoder(w.Body).Decode(&status); err != nil { - t.Fatalf("decode: %v", err) - } - // No tasks → no commits → IncludesFix = false (nothing to check). - if status.IncludesFix { - t.Error("expected IncludesFix=false when no commits") - } - - // 404 for unknown story. - req2 := httptest.NewRequest("GET", "/api/stories/nonexistent/deployment-status", nil) - w2 := httptest.NewRecorder() - srv.mux.ServeHTTP(w2, req2) - if w2.Code != http.StatusNotFound { - t.Errorf("expected 404 for unknown story, got %d", w2.Code) - } -} diff --git a/internal/api/taskops.go b/internal/api/taskops.go index 881babe..254d73c 100644 --- a/internal/api/taskops.go +++ b/internal/api/taskops.go @@ -128,9 +128,6 @@ func (s *Server) acceptTask(ctx context.Context, id string, actor event.Actor) e if err := s.store.UpdateTaskStateBy(id, task.StateCompleted, actor); err != nil { return err } - if t.StoryID != "" { - go s.pool.CheckStoryCompletion(ctx, t.StoryID) - } return nil } diff --git a/internal/api/validate.go b/internal/api/validate.go index 07d293c..9e91882 100644 --- a/internal/api/validate.go +++ b/internal/api/validate.go @@ -7,11 +7,29 @@ import ( "fmt" "net/http" "os/exec" + "strings" "time" ) const validateTimeout = 20 * time.Second +// claudeJSONResult is the top-level object returned by `claude --output-format json`. +type claudeJSONResult struct { + Result string `json:"result"` + IsError bool `json:"is_error"` +} + +// extractJSON returns the first top-level JSON object found in s, stripping +// surrounding prose or markdown code fences the model may have added. +func extractJSON(s string) string { + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start == -1 || end == -1 || end < start { + return s + } + return s[start : end+1] +} + const validateSystemPrompt = `You are a task instruction reviewer for Claudomator, an AI task runner that executes tasks by running Claude or Gemini as a subprocess. Analyze the given task name and instructions for clarity and completeness. diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go index 05fe82c..f0ede7c 100644 --- a/internal/api/webhook_test.go +++ b/internal/api/webhook_test.go @@ -422,11 +422,21 @@ func TestGitHubWebhook_PushEvent_SyncsLocalBareRepo(t *testing.T) { // Create a "github" source bare repo with a commit. githubBare := filepath.Join(dir, "github.git") localBare := filepath.Join(dir, "local.git") - for _, bare := range []string{githubBare, localBare} { - if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { - t.Fatalf("git init bare %s: %v\n%s", bare, err, out) + initBare := func(path string) { + t.Helper() + out, err := exec.Command("git", "init", "--bare", "--initial-branch=main", path).CombinedOutput() + if err != nil { + // Fallback for older git: init then update HEAD to main. + if out2, err2 := exec.Command("git", "init", "--bare", path).CombinedOutput(); err2 != nil { + t.Fatalf("git init bare %s: %v\n%s", path, err2, out2) + } + if out2, err2 := exec.Command("git", "-C", path, "symbolic-ref", "HEAD", "refs/heads/main").CombinedOutput(); err2 != nil { + t.Fatalf("set HEAD to main %s: %v\n%s\n%s", path, err, out, out2) + } } } + initBare(githubBare) + initBare(localBare) // Seed githubBare with a commit via a temp clone. clone := filepath.Join(dir, "clone") run := func(args ...string) { diff --git a/internal/cli/serve.go b/internal/cli/serve.go index f687e14..19db2f8 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -182,7 +182,7 @@ func serve(addr, basePath string) error { pool.RecoverStaleQueued(context.Background()) pool.RecoverStaleBlocked() - srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) + srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath) srv.SetBasePath(basePath) if accountant != nil { srv.SetBudget(accountant) diff --git a/internal/executor/container.go b/internal/executor/container.go index a00448c..6179ffc 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -29,7 +29,7 @@ type ContainerRunner struct { GeminiBinary string // optional path to gemini binary in container ClaudeConfigDir string // host path to ~/.claude; mounted into container for auth credentials CredentialSyncCmd string // optional path to sync-credentials script for auth-error auto-recovery - Store Store // optional; used to look up stories and projects for story-aware cloning + Store Store // optional; used to look up projects for local-path cloning // Registry mints the per-task MCP token; when set, the agent gets an // mcp-config pointing at the host agent MCP server. Registry *Registry @@ -62,16 +62,16 @@ func (r *ContainerRunner) ExecLogDir(execID string) string { return filepath.Join(r.LogDir, execID) } -// ensureStoryBranch checks whether branchName exists in remoteURL and creates -// it from main if not. Uses localPath as a reference clone for speed if set. -func (r *ContainerRunner) ensureStoryBranch(ctx context.Context, remoteURL, branchName, localPath string) error { +// ensureBranch checks whether branchName exists in remoteURL and creates +// it from main if it does not exist. Uses localPath as a reference clone for speed if set. +func (r *ContainerRunner) ensureBranch(ctx context.Context, remoteURL, branchName, localPath string) error { // Check if branch already exists. out, err := r.command(ctx, "git", "ls-remote", "--heads", remoteURL, branchName).CombinedOutput() if err == nil && len(strings.TrimSpace(string(out))) > 0 { return nil // already exists } - r.Logger.Info("story branch missing, creating from main", "branch", branchName, "remote", remoteURL) + r.Logger.Info("branch missing, creating from main", "branch", branchName, "remote", remoteURL) // Clone into a temp dir so we can create the branch. tmp, err := os.MkdirTemp("", "claudomator-branchsetup-*") @@ -142,38 +142,21 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } }() - // Resolve story branch and project local path if this is a story task. - var storyBranch string - var storyLocalPath string - if t.StoryID != "" && r.Store != nil { - if story, err := r.Store.GetStory(t.StoryID); err == nil && story != nil { - storyBranch = story.BranchName - if story.ProjectID != "" { - if proj, err := r.Store.GetProject(story.ProjectID); err == nil && proj != nil { - storyLocalPath = proj.LocalPath - } - } - } - } - // For non-story tasks (e.g. CI webhook tasks), also resolve local path from the task's - // project field. This lets the runner clone from the local mirror instead of an HTTPS - // remote that may require credentials not available on the host. - if storyLocalPath == "" && t.Project != "" && r.Store != nil { + // Resolve branch and local path for cloning. + branch := t.BranchName + var localPath string + // Resolve local path from project field to avoid HTTPS auth requirements. + if t.Project != "" && r.Store != nil { if proj, err := r.Store.GetProject(t.Project); err == nil && proj != nil && proj.LocalPath != "" { - storyLocalPath = proj.LocalPath + localPath = proj.LocalPath } } - // Fall back to task-level BranchName (e.g. set explicitly by executor or tests). - if storyBranch == "" { - storyBranch = t.BranchName - } - // 2. Ensure story branch exists in the remote before cloning. - // If the branch is missing (e.g. story approved before fix, or branch push failed), - // create it from main using the project local path as a reference repo. - if storyBranch != "" && !isResume { - if err := r.ensureStoryBranch(ctx, repoURL, storyBranch, storyLocalPath); err != nil { - r.Logger.Warn("ensureStoryBranch failed (will attempt checkout anyway)", "branch", storyBranch, "error", err) + // 2. Ensure branch exists in the remote before cloning. + // If the branch is missing, create it from main using the project local path as a reference repo. + if branch != "" && !isResume { + if err := r.ensureBranch(ctx, repoURL, branch, localPath); err != nil { + r.Logger.Warn("ensureBranch failed (will attempt checkout anyway)", "branch", branch, "error", err) } } @@ -184,21 +167,19 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec return fmt.Errorf("removing workspace before clone: %w", err) } // Prefer the local path as the clone source to avoid HTTPS auth requirements. - // For story tasks the storyLocalPath is also available as a speed reference, - // but we clone from the local path directly since it is already authoritative. cloneSrc := repoURL - if storyLocalPath != "" { - cloneSrc = storyLocalPath + if localPath != "" { + cloneSrc = localPath } r.Logger.Info("cloning repository", "src", cloneSrc, "workspace", workspace) cloneArgs := []string{"clone", cloneSrc, workspace} if out, err := r.command(ctx, "git", cloneArgs...).CombinedOutput(); err != nil { return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) } - if storyBranch != "" { - r.Logger.Info("checking out story branch", "branch", storyBranch) - if out, err := r.command(ctx, "git", "-C", workspace, "checkout", storyBranch).CombinedOutput(); err != nil { - return fmt.Errorf("git checkout story branch %q failed: %w\n%s", storyBranch, err, string(out)) + if branch != "" { + r.Logger.Info("checking out branch", "branch", branch) + if out, err := r.command(ctx, "git", "-C", workspace, "checkout", branch).CombinedOutput(); err != nil { + return fmt.Errorf("git checkout branch %q failed: %w\n%s", branch, err, string(out)) } } if err = os.Chmod(workspace, 0755); err != nil { @@ -241,7 +222,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } // Run container (with auth retry on failure). - runErr := r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch, ch) + runErr := r.runContainer(ctx, t, e, workspace, agentHome, isResume, branch, ch) if runErr != nil && isAuthError(runErr) && r.CredentialSyncCmd != "" { r.Logger.Warn("auth failure detected, syncing credentials and retrying once", "taskID", t.ID) syncOut, syncErr := r.command(ctx, r.CredentialSyncCmd).CombinedOutput() @@ -255,7 +236,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec if srcData, readErr := os.ReadFile(filepath.Join(r.ClaudeConfigDir, ".claude.json")); readErr == nil { _ = os.WriteFile(filepath.Join(agentHome, ".claude.json"), srcData, 0644) } - runErr = r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch, ch) + runErr = r.runContainer(ctx, t, e, workspace, agentHome, isResume, branch, ch) } if runErr == nil { @@ -271,7 +252,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec // runContainer runs the docker container for the given task and handles log setup, // environment files, instructions, and post-execution git operations. -func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *storage.Execution, workspace, agentHome string, isResume bool, storyBranch string, ch AgentChannel) error { +func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *storage.Execution, workspace, agentHome string, isResume bool, branch string, ch AgentChannel) error { repoURL := t.RepositoryURL image := t.Agent.ContainerImage @@ -426,8 +407,8 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto if hasCommits { pushRef := "HEAD" - if storyBranch != "" { - pushRef = storyBranch + if branch != "" { + pushRef = branch } r.Logger.Info("pushing changes back to remote", "url", repoURL, "ref", pushRef) if out, err := r.command(ctx, "git", "-C", workspace, "push", "origin", pushRef).CombinedOutput(); err != nil { diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index 8c00a0f..b06d153 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -620,9 +620,9 @@ func TestContainerRunner_ClonesStoryBranch(t *testing.T) { runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) os.RemoveAll(e.SandboxDir) - // Assert git checkout was called with the story branch name. + // Assert git checkout was called with the branch name. if len(checkoutArgs) == 0 { - t.Fatal("expected git checkout to be called for story branch, but it was not") + t.Fatal("expected git checkout to be called for branch, but it was not") } found := false for _, a := range checkoutArgs { @@ -741,27 +741,30 @@ func TestContainerRunner_ClonesFromLocalPathForCITask(t *testing.T) { } } -func TestEnsureStoryBranch_CreatesMissingBranch(t *testing.T) { +func TestEnsureBranch_CreatesMissingBranch(t *testing.T) { // Set up a bare repo and a local clone to test branch creation. dir := t.TempDir() bare := filepath.Join(dir, "bare.git") local := filepath.Join(dir, "local") - // Create bare repo with an initial commit. - if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { - t.Fatalf("git init bare: %v\n%s", err, out) + // Create bare repo with an initial commit on main. + if out, err := exec.Command("git", "init", "--bare", "--initial-branch=main", bare).CombinedOutput(); err != nil { + // Fallback for older git: init bare then update HEAD manually. + if out2, err2 := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err2 != nil { + t.Fatalf("git init bare: %v\n%s", err2, out2) + } + if out2, err2 := exec.Command("git", "-C", bare, "symbolic-ref", "HEAD", "refs/heads/main").CombinedOutput(); err2 != nil { + t.Fatalf("set HEAD to main: %v\n%s\n%s", err, out, out2) + } } if out, err := exec.Command("git", "clone", bare, local).CombinedOutput(); err != nil { t.Fatalf("git clone: %v\n%s", err, out) } - if out, err := exec.Command("git", "-C", local, "commit", "--allow-empty", "-m", "init").CombinedOutput(); err != nil { + if out, err := exec.Command("git", "-C", local, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "init").CombinedOutput(); err != nil { t.Fatalf("git commit: %v\n%s", err, out) } if out, err := exec.Command("git", "-C", local, "push", "origin", "main").CombinedOutput(); err != nil { - // try master - if out2, err2 := exec.Command("git", "-C", local, "push", "origin", "HEAD:main").CombinedOutput(); err2 != nil { - t.Fatalf("git push main: %v\n%s\n%s", err, out, out2) - } + t.Fatalf("git push main: %v\n%s", err, out) } runner := &ContainerRunner{Logger: slog.Default()} @@ -771,21 +774,21 @@ func TestEnsureStoryBranch_CreatesMissingBranch(t *testing.T) { // Branch should not exist yet. out, _ := exec.Command("git", "ls-remote", "--heads", bare, branch).CombinedOutput() if len(strings.TrimSpace(string(out))) > 0 { - t.Fatal("branch should not exist before ensureStoryBranch") + t.Fatal("branch should not exist before ensureBranch") } - if err := runner.ensureStoryBranch(context.Background(), bare, branch, ""); err != nil { - t.Fatalf("ensureStoryBranch: %v", err) + if err := runner.ensureBranch(context.Background(), bare, branch, ""); err != nil { + t.Fatalf("ensureBranch: %v", err) } // Branch should now exist in the bare repo. out, err := exec.Command("git", "ls-remote", "--heads", bare, branch).CombinedOutput() if err != nil || len(strings.TrimSpace(string(out))) == 0 { - t.Errorf("branch %q not found in bare repo after ensureStoryBranch: %s", branch, out) + t.Errorf("branch %q not found in bare repo after ensureBranch: %s", branch, out) } } -func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) { +func TestEnsureBranch_IdempotentIfExists(t *testing.T) { dir := t.TempDir() bare := filepath.Join(dir, "bare.git") local := filepath.Join(dir, "local") @@ -796,7 +799,7 @@ func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) { if out, err := exec.Command("git", "clone", bare, local).CombinedOutput(); err != nil { t.Fatalf("git clone: %v\n%s", err, out) } - if out, err := exec.Command("git", "-C", local, "commit", "--allow-empty", "-m", "init").CombinedOutput(); err != nil { + if out, err := exec.Command("git", "-C", local, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "init").CombinedOutput(); err != nil { t.Fatalf("git commit: %v\n%s", err, out) } if _, err := exec.Command("git", "-C", local, "push", "origin", "HEAD:main").CombinedOutput(); err != nil { @@ -815,8 +818,8 @@ func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) { runner := &ContainerRunner{Logger: slog.Default()} // Should be a no-op, not an error. - if err := runner.ensureStoryBranch(context.Background(), bare, branch, ""); err != nil { - t.Fatalf("ensureStoryBranch on existing branch: %v", err) + if err := runner.ensureBranch(context.Background(), bare, branch, ""); err != nil { + t.Fatalf("ensureBranch on existing branch: %v", err) } } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 507c65b..51cf5d9 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -2,11 +2,9 @@ package executor import ( "context" - "encoding/json" "errors" "fmt" "log/slog" - "os/exec" "path/filepath" "strings" "sync" @@ -38,13 +36,8 @@ type Store interface { UpdateExecutionChangestats(execID string, stats *task.Changestats) error RecordAgentEvent(e storage.AgentEvent) error GetProject(id string) (*task.Project, error) - GetStory(id string) (*task.Story, error) - ListTasksByStory(storyID string) ([]*task.Task, error) - UpdateStoryStatus(id string, status task.StoryState) error CreateTask(t *task.Task) error CreateEvent(e *event.Event) error - UpdateTaskCheckerReport(id, report string) error - GetCheckerTask(checkedTaskID string) (*task.Task, error) } // LogPather is an optional interface runners can implement to provide the log @@ -400,12 +393,6 @@ func (p *Pool) executeResume(ctx context.Context, t *task.Task, exec *storage.Ex t.RepositoryURL = proj.RemoteURL } } - // Populate BranchName from Story if missing (ADR-007). - if t.BranchName == "" && t.StoryID != "" { - if story, err := p.store.GetStory(t.StoryID); err == nil && story.BranchName != "" { - t.BranchName = story.BranchName - } - } sc := newStoreChannel(p.store, t.ID) err = runner.Run(ctx, t, exec, sc) @@ -496,47 +483,11 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage. p.consecutiveFailures[agentType]++ p.mu.Unlock() } - // If this is a checker task, attach the failure report for any terminal - // failure state (FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED). - if t.CheckerForTaskID != "" && exec.ErrorMsg != "" { - if reportErr := p.store.UpdateTaskCheckerReport(t.CheckerForTaskID, exec.ErrorMsg); reportErr != nil { - p.logger.Error("handleRunResult: failed to set checker report", "taskID", t.CheckerForTaskID, "error", reportErr) - } - } - if t.StoryID != "" && exec.Status == "FAILED" { - storyID := t.StoryID - errMsg := exec.ErrorMsg - go func() { - story, getErr := p.store.GetStory(storyID) - if getErr != nil { - return - } - if story.Status == task.StoryValidating { - p.checkValidationResult(ctx, storyID, task.StateFailed, errMsg) - } - }() - } } else { p.mu.Lock() p.consecutiveFailures[agentType] = 0 p.mu.Unlock() - if t.CheckerForTaskID != "" { - // Checker task succeeded — auto-accept the checked task. - exec.Status = "COMPLETED" - if err := p.store.UpdateTaskState(t.ID, task.StateCompleted); err != nil { - p.logger.Error("handleRunResult: failed to complete checker task", "taskID", t.ID, "error", err) - } - checkedTask, getErr := p.store.GetTask(t.CheckerForTaskID) - if getErr == nil { - if acceptErr := p.store.UpdateTaskState(t.CheckerForTaskID, task.StateCompleted); acceptErr != nil { - p.logger.Error("handleRunResult: failed to auto-accept checked task", "taskID", t.CheckerForTaskID, "error", acceptErr) - } else if checkedTask.StoryID != "" { - go p.checkStoryCompletion(context.Background(), checkedTask.StoryID) - } - } else { - p.logger.Error("handleRunResult: failed to get checked task", "taskID", t.CheckerForTaskID, "error", getErr) - } - } else if t.ParentTaskID == "" { + if t.ParentTaskID == "" { subtasks, subErr := p.store.ListSubtasks(t.ID) if subErr != nil { p.logger.Error("failed to list subtasks", "taskID", t.ID, "error", subErr) @@ -551,9 +502,6 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage. if err := p.store.UpdateTaskState(t.ID, task.StateReady); err != nil { p.logger.Error("failed to update task state", "taskID", t.ID, "state", task.StateReady, "error", err) } - if agentType != "local" { - go p.spawnCheckerTask(context.Background(), t, exec) - } } } else { exec.Status = "COMPLETED" @@ -562,21 +510,6 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage. } p.maybeUnblockParent(t.ParentTaskID) } - if t.StoryID != "" { - storyID := t.StoryID - go func() { - story, getErr := p.store.GetStory(storyID) - if getErr != nil { - p.logger.Error("handleRunResult: failed to get story", "storyID", storyID, "error", getErr) - return - } - if story.Status == task.StoryValidating { - p.checkValidationResult(ctx, storyID, task.StateCompleted, "") - } else { - p.checkStoryCompletion(ctx, storyID) - } - }() - } } summary := exec.Summary @@ -591,13 +524,6 @@ 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) } } - terminalFailure := exec.Status == "FAILED" || exec.Status == "TIMED_OUT" || exec.Status == "CANCELLED" || exec.Status == "BUDGET_EXCEEDED" - if t.CheckerForTaskID != "" && terminalFailure && summary != "" { - // Overwrite the initial error-message report with the richer summary. - if reportErr := p.store.UpdateTaskCheckerReport(t.CheckerForTaskID, summary); reportErr != nil { - p.logger.Error("handleRunResult: failed to update checker report with summary", "taskID", t.CheckerForTaskID, "error", reportErr) - } - } if exec.StdoutPath != "" { if cs := task.ParseChangestatFromFile(exec.StdoutPath); cs != nil { exec.Changestats = cs @@ -612,266 +538,6 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage. p.resultCh <- &Result{TaskID: t.ID, Execution: exec, Err: err} } -// checkStoryCompletion checks whether all top-level tasks in a story have reached -// a terminal success state and transitions the story to SHIPPABLE if so. -// Subtasks are intentionally excluded — a parent task reaching READY/COMPLETED -// already accounts for its subtasks. -// CheckStoryCompletion is the exported entry point for story completion checks -// called from outside the package (e.g. the API accept handler). -func (p *Pool) CheckStoryCompletion(ctx context.Context, storyID string) { - p.checkStoryCompletion(ctx, storyID) -} - -func (p *Pool) checkStoryCompletion(ctx context.Context, storyID string) { - story, err := p.store.GetStory(storyID) - if err != nil { - p.logger.Error("checkStoryCompletion: failed to get story", "storyID", storyID, "error", err) - return - } - if story.Status != task.StoryInProgress { - return // already SHIPPABLE or beyond — nothing to do - } - tasks, err := p.store.ListTasksByStory(storyID) - if err != nil { - p.logger.Error("checkStoryCompletion: failed to list tasks", "storyID", storyID, "error", err) - return - } - if len(tasks) == 0 { - return - } - topLevelCount := 0 - for _, t := range tasks { - if t.ParentTaskID != "" { - continue // subtasks are covered by their parent - } - topLevelCount++ - if t.State != task.StateCompleted { - return // not all top-level tasks done; READY alone is not sufficient (checker may be pending) - } - } - if topLevelCount == 0 { - return // no top-level tasks — don't auto-complete - } - if err := p.store.UpdateStoryStatus(storyID, task.StoryShippable); err != nil { - p.logger.Error("checkStoryCompletion: failed to update story status", "storyID", storyID, "error", err) - return - } - p.logger.Info("story transitioned to SHIPPABLE", "storyID", storyID) - // Deploy is now triggered explicitly by the human via POST /api/stories/{id}/ship. -} - -// ShipStory merges the story branch and runs the deploy script. -// Returns an error if the story is not in SHIPPABLE state. -func (p *Pool) ShipStory(ctx context.Context, storyID string) error { - story, err := p.store.GetStory(storyID) - if err != nil { - return fmt.Errorf("story not found: %w", err) - } - if story.Status != task.StoryShippable { - return fmt.Errorf("story is not SHIPPABLE (current status: %s)", story.Status) - } - go p.triggerStoryDeploy(context.Background(), storyID) - return nil -} - -// spawnCheckerTask creates and submits a checker task for the given completed task. -// Guards: not called for subtasks, checker tasks, tasks without a repository URL, -// or tasks that already have a checker. -func (p *Pool) spawnCheckerTask(ctx context.Context, checked *task.Task, exec *storage.Execution) { - // Never spawn a checker for subtasks, checker tasks, or tasks without a repository. - if checked.ParentTaskID != "" || checked.CheckerForTaskID != "" || checked.RepositoryURL == "" { - return - } - // Idempotent: don't create a second checker if one already exists. - existing, err := p.store.GetCheckerTask(checked.ID) - if err != nil { - p.logger.Error("spawnCheckerTask: GetCheckerTask failed", "taskID", checked.ID, "error", err) - return - } - if existing != nil { - return - } - - criteria := checked.AcceptanceCriteria - if criteria == "" { - criteria = checked.Agent.Instructions - } - - // Embed the execution log so the checker can verify output without needing - // Docker, API access, or repository cloning. - logSnippet := "" - if exec != nil && exec.StdoutPath != "" { - logSnippet = "\n\nExecution log (last 200 lines):\n<execution_log>\n" + - tailFile(exec.StdoutPath, 200) + - "\n</execution_log>" - } - - instructions := fmt.Sprintf(`You are validating a completed task. Do not make any changes to the code or repository. - -Task: %s -Instructions given to the implementor: -%s - -Acceptance criteria: -%s%s - -Steps: -1. Review the execution log above to determine what the agent did. -2. If the task involved code changes, use Read/Grep/Glob to inspect the repository at %s. -3. Verify each acceptance criterion is met. Run tests or make HTTP requests as needed. -4. If all criteria are satisfied, exit normally (success). -5. If any criterion is not met, use the Bash tool to exit with a non-zero code: - bash -c "exit 1" - Before exiting, write a brief summary of what failed.`, - checked.Name, checked.Agent.Instructions, criteria, logSnippet, checked.RepositoryURL) - - now := time.Now().UTC() - checker := &task.Task{ - ID: uuid.New().String(), - Name: "Check: " + checked.Name, - CheckerForTaskID: checked.ID, - RepositoryURL: checked.RepositoryURL, - Agent: task.AgentConfig{ - Type: "claude", - Instructions: instructions, - MaxBudgetUSD: 0.50, - AllowedTools: []string{"Bash", "Read", "Glob", "Grep"}, - }, - Timeout: task.Duration{Duration: 10 * time.Minute}, - Priority: task.PriorityNormal, - Tags: []string{}, - DependsOn: []string{}, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, - State: task.StatePending, - CreatedAt: now, - UpdatedAt: now, - } - - if err := p.store.CreateTask(checker); err != nil { - p.logger.Error("spawnCheckerTask: CreateTask failed", "error", err) - return - } - checker.State = task.StateQueued - if err := p.store.UpdateTaskState(checker.ID, task.StateQueued); err != nil { - p.logger.Error("spawnCheckerTask: UpdateTaskState failed", "error", err) - return - } - if err := p.Submit(ctx, checker); err != nil { - p.logger.Error("spawnCheckerTask: Submit failed", "error", err) - } -} - -// triggerStoryDeploy runs the project deploy script for a SHIPPABLE story -// and advances it to DEPLOYED on success. -func (p *Pool) triggerStoryDeploy(ctx context.Context, storyID string) { - story, err := p.store.GetStory(storyID) - if err != nil { - p.logger.Error("triggerStoryDeploy: failed to get story", "storyID", storyID, "error", err) - return - } - if story.ProjectID == "" { - return - } - proj, err := p.store.GetProject(story.ProjectID) - if err != nil { - p.logger.Error("triggerStoryDeploy: failed to get project", "storyID", storyID, "projectID", story.ProjectID, "error", err) - return - } - if proj.DeployScript == "" { - return - } - // Merge story branch to main before deploying (ADR-007). - if story.BranchName != "" && proj.LocalPath != "" { - mergeSteps := [][]string{ - {"git", "-C", proj.LocalPath, "fetch", "origin"}, - {"git", "-C", proj.LocalPath, "checkout", "main"}, - {"git", "-C", proj.LocalPath, "merge", "--no-ff", story.BranchName, "-m", "Merge " + story.BranchName}, - {"git", "-C", proj.LocalPath, "push", "origin", "main"}, - } - for _, args := range mergeSteps { - if mergeOut, mergeErr := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); mergeErr != nil { - p.logger.Error("triggerStoryDeploy: merge failed", "cmd", args, "output", string(mergeOut), "error", mergeErr) - return - } - } - p.logger.Info("story branch merged to main", "storyID", storyID, "branch", story.BranchName) - } - out, err := exec.CommandContext(ctx, proj.DeployScript).CombinedOutput() - if err != nil { - p.logger.Error("triggerStoryDeploy: deploy script failed", "storyID", storyID, "script", proj.DeployScript, "output", string(out), "error", err) - return - } - if err := p.store.UpdateStoryStatus(storyID, task.StoryDeployed); err != nil { - p.logger.Error("triggerStoryDeploy: failed to update story status", "storyID", storyID, "error", err) - return - } - p.logger.Info("story transitioned to DEPLOYED", "storyID", storyID) - go p.createValidationTask(ctx, storyID) -} - -// createValidationTask creates a validation subtask from the story's ValidationJSON -// and transitions the story to VALIDATING. -func (p *Pool) createValidationTask(ctx context.Context, storyID string) { - story, err := p.store.GetStory(storyID) - if err != nil { - p.logger.Error("createValidationTask: failed to get story", "storyID", storyID, "error", err) - return - } - if story.ValidationJSON == "" { - p.logger.Warn("createValidationTask: story has no ValidationJSON, skipping", "storyID", storyID) - return - } - - var spec map[string]interface{} - if err := json.Unmarshal([]byte(story.ValidationJSON), &spec); err != nil { - p.logger.Error("createValidationTask: failed to parse ValidationJSON", "storyID", storyID, "error", err) - return - } - - instructions := fmt.Sprintf("Validate the deployment for story %q.\n\nValidation spec:\n%s", story.Name, story.ValidationJSON) - - now := time.Now().UTC() - vtask := &task.Task{ - ID: uuid.New().String(), - Name: fmt.Sprintf("validation: %s", story.Name), - StoryID: storyID, - State: task.StateQueued, - Agent: task.AgentConfig{Type: "claude", Instructions: instructions}, - Tags: []string{}, - DependsOn: []string{}, - CreatedAt: now, - UpdatedAt: now, - } - - if err := p.store.CreateTask(vtask); err != nil { - p.logger.Error("createValidationTask: failed to create task", "storyID", storyID, "error", err) - return - } - if err := p.store.UpdateStoryStatus(storyID, task.StoryValidating); err != nil { - p.logger.Error("createValidationTask: failed to update story status", "storyID", storyID, "error", err) - return - } - p.logger.Info("validation task created and story transitioned to VALIDATING", "storyID", storyID, "taskID", vtask.ID) - p.Submit(ctx, vtask) //nolint:errcheck -} - -// checkValidationResult inspects a completed validation task and transitions -// the story to REVIEW_READY or NEEDS_FIX accordingly. -func (p *Pool) checkValidationResult(ctx context.Context, storyID string, taskState task.State, errorMsg string) { - if taskState == task.StateCompleted { - if err := p.store.UpdateStoryStatus(storyID, task.StoryReviewReady); err != nil { - p.logger.Error("checkValidationResult: failed to update story status", "storyID", storyID, "error", err) - return - } - p.logger.Info("story transitioned to REVIEW_READY", "storyID", storyID) - } else { - if err := p.store.UpdateStoryStatus(storyID, task.StoryNeedsFix); err != nil { - p.logger.Error("checkValidationResult: failed to update story status", "storyID", storyID, "error", err) - return - } - p.logger.Info("story transitioned to NEEDS_FIX", "storyID", storyID, "error", errorMsg) - } -} // ActiveCount returns the number of currently running tasks. func (p *Pool) ActiveCount() int { @@ -1137,12 +803,6 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { t.RepositoryURL = proj.RemoteURL } } - // Populate BranchName from Story if missing (ADR-007). - if t.BranchName == "" && t.StoryID != "" { - if story, err := p.store.GetStory(t.StoryID); err == nil && story.BranchName != "" { - t.BranchName = story.BranchName - } - } // Run the task. sc := newStoreChannel(p.store, t.ID) @@ -1220,11 +880,9 @@ func (p *Pool) RecoverStaleQueued(ctx context.Context) { // RecoverStaleBlocked promotes any BLOCKED or QUEUED parent task to READY when // all of its subtasks are already COMPLETED. This handles the case where the // server was restarted after subtasks finished but before maybeUnblockParent -// could fire, and also the case where story approval pre-created subtasks -// without ever running the parent task. +// could fire. // Call this once on server startup, after RecoverStaleRunning and RecoverStaleQueued. func (p *Pool) RecoverStaleBlocked() { - ctx := context.Background() for _, state := range []task.State{task.StateBlocked, task.StateQueued} { tasks, err := p.store.ListTasks(storage.TaskFilter{State: state}) if err != nil { @@ -1235,12 +893,7 @@ func (p *Pool) RecoverStaleBlocked() { if t.ParentTaskID != "" { continue // only promote actual parents } - before := t.State p.maybeUnblockParent(t.ID) - // If the parent was promoted, check story completion. - if after, err := p.store.GetTask(t.ID); err == nil && after.State != before && t.StoryID != "" { - p.checkStoryCompletion(ctx, t.StoryID) - } } } } @@ -1318,7 +971,7 @@ func withFailureHistory(t *task.Task, execs []*storage.Execution, err error) *ta // maybeUnblockParent transitions the parent task to READY if all of its subtasks // are in the COMPLETED state. Handles both BLOCKED parents (ran, created subtasks, -// paused) and QUEUED parents (story approval created subtasks without running parent). +// paused) and QUEUED parents (subtasks created before parent ran). func (p *Pool) maybeUnblockParent(parentID string) { parent, err := p.store.GetTask(parentID) if err != nil { diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index b737e22..cb6554c 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" "os" - "os/exec" "path/filepath" "strings" "sync" @@ -805,53 +804,6 @@ func TestPool_RecoverStaleBlocked_KeepsBlockedWhenSubtaskIncomplete(t *testing.T } } -func TestPool_RecoverStaleBlocked_PromotesQueuedParentWithAllSubtasksDone(t *testing.T) { - store := testStore(t) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) - - now := time.Now().UTC() - story := &task.Story{ - ID: "story-queued-parent", Name: "Queued Parent Story", - Status: task.StoryInProgress, CreatedAt: now, UpdatedAt: now, - } - store.CreateStory(story) - - // Parent task stuck QUEUED (approved with pre-created subtasks, never run). - parent := makeTask("queued-parent-1") - parent.State = task.StateQueued - parent.StoryID = story.ID - store.CreateTask(parent) - - for i := 0; i < 2; i++ { - sub := makeTask(fmt.Sprintf("queued-sub-%d", i)) - sub.ParentTaskID = parent.ID - sub.StoryID = story.ID - sub.State = task.StateCompleted - store.CreateTask(sub) - } - - pool.RecoverStaleBlocked() - - got, err := store.GetTask(parent.ID) - if err != nil { - t.Fatalf("GetTask: %v", err) - } - if got.State != task.StateReady { - t.Errorf("parent state: want READY, got %s", got.State) - } - - // Story should still be IN_PROGRESS — READY tasks don't satisfy the completion check; - // the task must be accepted (READY → COMPLETED) before the story advances to SHIPPABLE. - s, err := store.GetStory(story.ID) - if err != nil { - t.Fatalf("GetStory: %v", err) - } - if s.Status != task.StoryInProgress { - t.Errorf("story status: want IN_PROGRESS, got %s", s.Status) - } -} - // TestPool_RecoverStaleBlocked_DoesNotPromoteQueuedLeafTask verifies that a top-level // QUEUED task with NO subtasks is not promoted to READY by RecoverStaleBlocked. // This guards against the bug where a task that failed to start (stuck in QUEUED due @@ -878,46 +830,6 @@ func TestPool_RecoverStaleBlocked_DoesNotPromoteQueuedLeafTask(t *testing.T) { } } -// TestPool_CheckStoryCompletion_ReadyTasksNotSufficient verifies that READY tasks -// alone do not advance a story to SHIPPABLE — tasks must be COMPLETED. -func TestPool_CheckStoryCompletion_ReadyTasksNotSufficient(t *testing.T) { - store := testStore(t) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) - - now := time.Now().UTC() - story := &task.Story{ - ID: "story-ready-only", - Name: "Ready Only Story", - Status: task.StoryInProgress, - CreatedAt: now, - UpdatedAt: now, - } - store.CreateStory(story) - - // One task driven to READY (checker pending), one COMPLETED. - tk1 := makeTask("ro-task-1") - tk1.StoryID = story.ID - store.CreateTask(tk1) - for _, s := range []task.State{task.StateQueued, task.StateRunning, task.StateReady} { - store.UpdateTaskState(tk1.ID, s) - } - - tk2 := makeTask("ro-task-2") - tk2.StoryID = story.ID - store.CreateTask(tk2) - for _, s := range []task.State{task.StateQueued, task.StateRunning, task.StateReady, task.StateCompleted} { - store.UpdateTaskState(tk2.ID, s) - } - - pool.checkStoryCompletion(context.Background(), story.ID) - - got, _ := store.GetStory(story.ID) - if got.Status != task.StoryInProgress { - t.Errorf("story status: want IN_PROGRESS (tk1 still READY/checker pending), got %s", got.Status) - } -} - func TestPool_ActivePerAgent_DeletesZeroEntries(t *testing.T) { store := testStore(t) runner := &mockRunner{} @@ -1248,13 +1160,8 @@ func (m *minimalMockStore) UpdateExecutionChangestats(execID string, stats *task } func (m *minimalMockStore) RecordAgentEvent(_ storage.AgentEvent) error { return nil } func (m *minimalMockStore) GetProject(_ string) (*task.Project, error) { return nil, nil } -func (m *minimalMockStore) GetStory(_ string) (*task.Story, error) { return nil, nil } -func (m *minimalMockStore) ListTasksByStory(_ string) ([]*task.Task, error) { return nil, nil } -func (m *minimalMockStore) UpdateStoryStatus(_ string, _ task.StoryState) error { return nil } -func (m *minimalMockStore) CreateTask(_ *task.Task) error { return nil } -func (m *minimalMockStore) CreateEvent(_ *event.Event) error { return nil } -func (m *minimalMockStore) UpdateTaskCheckerReport(_ string, _ string) error { return nil } -func (m *minimalMockStore) GetCheckerTask(_ string) (*task.Task, error) { return nil, nil } +func (m *minimalMockStore) CreateTask(_ *task.Task) error { return nil } +func (m *minimalMockStore) CreateEvent(_ *event.Event) error { return nil } func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) { m.mu.Lock() @@ -1778,290 +1685,6 @@ func TestPool_ConsecutiveFailures_ResetOnSuccess(t *testing.T) { } } -func TestPool_CheckStoryCompletion_AllComplete(t *testing.T) { - store := testStore(t) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) - - // Create a story in IN_PROGRESS state. - now := time.Now().UTC() - story := &task.Story{ - ID: "story-comp-1", - Name: "Completion Test", - Status: task.StoryInProgress, - CreatedAt: now, - UpdatedAt: now, - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - // Create two top-level story tasks and drive them through valid transitions to COMPLETED. - for i, id := range []string{"sctask-1", "sctask-2"} { - tk := makeTask(id) - tk.StoryID = "story-comp-1" - tk.State = task.StatePending - if err := store.CreateTask(tk); err != nil { - t.Fatalf("CreateTask %d: %v", i, err) - } - for _, s := range []task.State{task.StateQueued, task.StateRunning, task.StateReady, task.StateCompleted} { - if err := store.UpdateTaskState(id, s); err != nil { - t.Fatalf("UpdateTaskState %s → %s: %v", id, s, err) - } - } - } - - pool.checkStoryCompletion(context.Background(), "story-comp-1") - - got, err := store.GetStory("story-comp-1") - if err != nil { - t.Fatalf("GetStory: %v", err) - } - if got.Status != task.StoryShippable { - t.Errorf("story status: want SHIPPABLE, got %v", got.Status) - } -} - -func TestPool_CheckStoryCompletion_PartialComplete(t *testing.T) { - store := testStore(t) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) - - now := time.Now().UTC() - story := &task.Story{ - ID: "story-partial-1", - Name: "Partial Test", - Status: task.StoryInProgress, - CreatedAt: now, - UpdatedAt: now, - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - // First top-level task driven to READY. - tk1 := makeTask("sptask-1") - tk1.StoryID = "story-partial-1" - store.CreateTask(tk1) - for _, s := range []task.State{task.StateQueued, task.StateRunning, task.StateReady} { - store.UpdateTaskState("sptask-1", s) - } - - // Second top-level task still in PENDING (not done). - tk2 := makeTask("sptask-2") - tk2.StoryID = "story-partial-1" - store.CreateTask(tk2) - - pool.checkStoryCompletion(context.Background(), "story-partial-1") - - got, err := store.GetStory("story-partial-1") - if err != nil { - t.Fatalf("GetStory: %v", err) - } - if got.Status != task.StoryInProgress { - t.Errorf("story status: want IN_PROGRESS (no transition), got %v", got.Status) - } -} - -func TestPool_StoryDeploy_RunsDeployScript(t *testing.T) { - store := testStore(t) - runner := &mockRunner{} - runners := map[string]Runner{"claude": runner} - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, runners, store, logger) - - // Create a deploy script that writes a marker file. - tmpDir := t.TempDir() - markerFile := filepath.Join(tmpDir, "deployed.marker") - scriptPath := filepath.Join(tmpDir, "deploy.sh") - scriptContent := "#!/bin/sh\ntouch " + markerFile + "\n" - if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { - t.Fatalf("write deploy script: %v", err) - } - - proj := &task.Project{ - ID: "proj-deploy-1", - Name: "Deploy Test Project", - DeployScript: scriptPath, - } - if err := store.CreateProject(proj); err != nil { - t.Fatalf("create project: %v", err) - } - - story := &task.Story{ - ID: "story-deploy-1", - Name: "Deploy Test Story", - ProjectID: proj.ID, - Status: task.StoryShippable, - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("create story: %v", err) - } - - pool.triggerStoryDeploy(context.Background(), story.ID) - - if _, err := os.Stat(markerFile); os.IsNotExist(err) { - t.Error("deploy script did not run: marker file not found") - } - - got, err := store.GetStory(story.ID) - if err != nil { - t.Fatalf("get story: %v", err) - } - if got.Status != task.StoryDeployed { - t.Errorf("story status: want DEPLOYED, got %q", got.Status) - } -} - -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - if dir != "" { - cmd.Dir = dir - } - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } -} - -func TestPool_StoryDeploy_MergesStoryBranch(t *testing.T) { - tmpDir := t.TempDir() - - // Set up bare repo + working copy with a story branch. - bareDir := filepath.Join(tmpDir, "bare.git") - localDir := filepath.Join(tmpDir, "local") - runGit(t, "", "init", "--bare", bareDir) - runGit(t, "", "clone", bareDir, localDir) - runGit(t, localDir, "config", "user.email", "test@test.com") - runGit(t, localDir, "config", "user.name", "Test") - - // Initial commit on main. - runGit(t, localDir, "checkout", "-b", "main") - os.WriteFile(filepath.Join(localDir, "README.md"), []byte("initial"), 0644) - runGit(t, localDir, "add", ".") - runGit(t, localDir, "commit", "-m", "initial") - runGit(t, localDir, "push", "-u", "origin", "main") - - // Story branch with a feature commit. - runGit(t, localDir, "checkout", "-b", "story/test-feature") - os.WriteFile(filepath.Join(localDir, "feature.go"), []byte("package main"), 0644) - runGit(t, localDir, "add", ".") - runGit(t, localDir, "commit", "-m", "feature work") - runGit(t, localDir, "push", "origin", "story/test-feature") - runGit(t, localDir, "checkout", "main") - - store := testStore(t) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) - - scriptPath := filepath.Join(tmpDir, "deploy.sh") - os.WriteFile(scriptPath, []byte("#!/bin/sh\nexit 0\n"), 0755) - - proj := &task.Project{ - ID: "proj-merge-1", Name: "Merge Test", - LocalPath: localDir, DeployScript: scriptPath, - } - if err := store.CreateProject(proj); err != nil { - t.Fatalf("create project: %v", err) - } - story := &task.Story{ - ID: "story-merge-1", Name: "Merge Test Story", - ProjectID: proj.ID, BranchName: "story/test-feature", - Status: task.StoryShippable, - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("create story: %v", err) - } - - pool.triggerStoryDeploy(context.Background(), story.ID) - - // feature.go should now be on main in the working copy. - if _, err := os.Stat(filepath.Join(localDir, "feature.go")); os.IsNotExist(err) { - t.Error("story branch was not merged to main: feature.go missing") - } - got, _ := store.GetStory(story.ID) - if got.Status != task.StoryDeployed { - t.Errorf("story status: want DEPLOYED, got %q", got.Status) - } -} - -func TestPool_PostDeploy_CreatesValidationTask(t *testing.T) { - store := testStore(t) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) - - now := time.Now().UTC() - validationSpec := `{"type":"smoke","steps":["curl /health"],"success_criteria":"status 200"}` - story := &task.Story{ - ID: "story-postdeploy-1", - Name: "Post Deploy Test", - Status: task.StoryDeployed, - ValidationJSON: validationSpec, - CreatedAt: now, - UpdatedAt: now, - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - pool.createValidationTask(context.Background(), story.ID) - - // Story should now be VALIDATING. - got, err := store.GetStory(story.ID) - if err != nil { - t.Fatalf("GetStory: %v", err) - } - if got.Status != task.StoryValidating { - t.Errorf("story status: want VALIDATING, got %q", got.Status) - } - - // A validation task should have been created. - tasks, err := store.ListTasksByStory(story.ID) - if err != nil { - t.Fatalf("ListTasksByStory: %v", err) - } - if len(tasks) == 0 { - t.Fatal("expected a validation task to be created, got none") - } - vtask := tasks[0] - if !strings.Contains(strings.ToLower(vtask.Name), "validation") { - t.Errorf("task name %q does not contain 'validation'", vtask.Name) - } - if vtask.StoryID != story.ID { - t.Errorf("task story_id: want %q, got %q", story.ID, vtask.StoryID) - } - if !strings.Contains(vtask.Agent.Instructions, "smoke") { - t.Errorf("task instructions %q do not reference validation spec content", vtask.Agent.Instructions) - } -} - -func TestPool_ValidationTask_Pass_SetsReviewReady(t *testing.T) { - store := testStore(t) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) - - now := time.Now().UTC() - story := &task.Story{ - ID: "story-val-pass-1", - Name: "Validation Pass", - Status: task.StoryValidating, - CreatedAt: now, - UpdatedAt: now, - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - pool.checkValidationResult(context.Background(), story.ID, task.StateCompleted, "") - - got, err := store.GetStory(story.ID) - if err != nil { - t.Fatalf("GetStory: %v", err) - } - if got.Status != task.StoryReviewReady { - t.Errorf("story status: want REVIEW_READY, got %q", got.Status) - } -} // TestPool_DependsOn_NoDeadlock verifies that a task waiting for a dependency // does NOT hold the per-agent slot, allowing the dependency to run first. @@ -2118,35 +1741,6 @@ func TestPool_DependsOn_NoDeadlock(t *testing.T) { } } -func TestPool_ValidationTask_Fail_SetsNeedsFix(t *testing.T) { - store := testStore(t) - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) - - now := time.Now().UTC() - story := &task.Story{ - ID: "story-val-fail-1", - Name: "Validation Fail", - Status: task.StoryValidating, - CreatedAt: now, - UpdatedAt: now, - } - if err := store.CreateStory(story); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - execErr := "smoke test failed: /health returned 503" - pool.checkValidationResult(context.Background(), story.ID, task.StateFailed, execErr) - - got, err := store.GetStory(story.ID) - if err != nil { - t.Fatalf("GetStory: %v", err) - } - if got.Status != task.StoryNeedsFix { - t.Errorf("story status: want NEEDS_FIX, got %q", got.Status) - } -} - func TestPool_Shutdown_WaitsForWorkers(t *testing.T) { store := testStore(t) started := make(chan struct{}) @@ -2229,141 +1823,3 @@ func TestPool_Shutdown_TimesOut(t *testing.T) { close(unblock) // cleanup } -func TestPool_CheckerSpawned_OnReady(t *testing.T) { - store := testStore(t) - runner := &mockRunner{} // succeeds instantly - pool := NewPool(2, map[string]Runner{"claude": runner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) - - tk := makeTask("checker-spawn-1") - tk.RepositoryURL = "https://github.com/x/y" - store.CreateTask(tk) - pool.Submit(context.Background(), tk) - <-pool.Results() // wait for original task to finish - - // Poll until the async spawnCheckerTask goroutine has written the checker task. - var checker *task.Task - var err error - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - checker, err = store.GetCheckerTask("checker-spawn-1") - if err != nil { - t.Fatalf("GetCheckerTask: %v", err) - } - if checker != nil { - break - } - time.Sleep(50 * time.Millisecond) - } - if checker == nil { - t.Fatal("expected a checker task to be created, got nil") - } - if checker.CheckerForTaskID != "checker-spawn-1" { - t.Errorf("expected CheckerForTaskID=checker-spawn-1, got %q", checker.CheckerForTaskID) - } -} - -func TestPool_CheckerNotSpawned_ForSubtask(t *testing.T) { - store := testStore(t) - runner := &mockRunner{} - pool := NewPool(2, map[string]Runner{"claude": runner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) - - parent := makeTask("no-checker-parent") - parent.RepositoryURL = "https://github.com/x/y" - store.CreateTask(parent) - - sub := makeTask("no-checker-sub") - sub.ParentTaskID = "no-checker-parent" - sub.RepositoryURL = "https://github.com/x/y" - store.CreateTask(sub) - - pool.Submit(context.Background(), sub) - <-pool.Results() - - time.Sleep(100 * time.Millisecond) - - checker, err := store.GetCheckerTask("no-checker-sub") - if err != nil { - t.Fatalf("GetCheckerTask: %v", err) - } - if checker != nil { - t.Error("expected no checker for subtask, but one was created") - } -} - -func TestPool_CheckerPass_AutoAcceptsTask(t *testing.T) { - store := testStore(t) - // Two-phase: first runner succeeds (original task), second also succeeds (checker). - runner := &mockRunner{ - onRun: func(t *task.Task, e *storage.Execution) error { - return nil // both original and checker succeed - }, - } - pool := NewPool(2, map[string]Runner{"claude": runner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) - - tk := makeTask("autoaccept-1") - tk.RepositoryURL = "https://github.com/x/y" - store.CreateTask(tk) - pool.Submit(context.Background(), tk) - <-pool.Results() // original finishes → READY + checker spawned - - // Wait for checker to run and complete. - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - got, _ := store.GetTask("autoaccept-1") - if got != nil && got.State == task.StateCompleted { - break - } - <-pool.Results() - } - - got, err := store.GetTask("autoaccept-1") - if err != nil { - t.Fatalf("GetTask: %v", err) - } - if got.State != task.StateCompleted { - t.Errorf("expected COMPLETED after checker pass, got %s", got.State) - } -} - -func TestPool_CheckerFail_AttachesReport(t *testing.T) { - store := testStore(t) - runner := &mockRunner{ - onRun: func(t *task.Task, e *storage.Execution) error { - if t.CheckerForTaskID != "" { - return fmt.Errorf("test suite failed: 3 failures") - } - return nil // original task succeeds - }, - } - pool := NewPool(2, map[string]Runner{"claude": runner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) - - tk := makeTask("fail-checker-1") - tk.RepositoryURL = "https://github.com/x/y" - store.CreateTask(tk) - pool.Submit(context.Background(), tk) - <-pool.Results() // original → READY - - // Wait for checker to fail. - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - got, _ := store.GetTask("fail-checker-1") - if got != nil && got.CheckerReport != "" { - break - } - select { - case <-pool.Results(): - case <-time.After(100 * time.Millisecond): - } - } - - got, err := store.GetTask("fail-checker-1") - if err != nil { - t.Fatalf("GetTask: %v", err) - } - if got.State != task.StateReady { - t.Errorf("expected task to stay READY after checker fail, got %s", got.State) - } - if got.CheckerReport == "" { - t.Error("expected checker_report to be set after checker failure") - } -} diff --git a/internal/storage/db.go b/internal/storage/db.go index e03a902..22a3d7b 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -123,21 +123,6 @@ func (s *DB) migrate() error { created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL )`, - `CREATE TABLE IF NOT EXISTS stories ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - project_id TEXT NOT NULL DEFAULT '', - branch_name TEXT NOT NULL DEFAULT '', - deploy_config TEXT NOT NULL DEFAULT '', - validation_json TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'PENDING', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - )`, - `ALTER TABLE tasks ADD COLUMN story_id TEXT`, - `ALTER TABLE tasks ADD COLUMN acceptance_criteria TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE tasks ADD COLUMN checker_for_task_id TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE tasks ADD COLUMN checker_report TEXT NOT NULL DEFAULT ''`, `ALTER TABLE executions ADD COLUMN tokens_in INTEGER`, `ALTER TABLE executions ADD COLUMN tokens_out INTEGER`, `ALTER TABLE executions ADD COLUMN agent TEXT`, @@ -198,12 +183,11 @@ func (s *DB) CreateTask(t *task.Task) error { defer tx.Rollback() //nolint:errcheck if _, err = tx.Exec(` - INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, story_id, acceptance_criteria, checker_for_task_id, checker_report) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, t.ID, t.Name, t.Description, t.ElaborationInput, t.Project, t.RepositoryURL, string(configJSON), string(t.Priority), t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON), - t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(), t.StoryID, - t.AcceptanceCriteria, t.CheckerForTaskID, t.CheckerReport, + t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(), ); err != nil { return err } @@ -230,13 +214,13 @@ func (s *DB) CreateTask(t *task.Task) error { // GetTask retrieves a task by ID. func (s *DB) GetTask(id string) (*task.Task, error) { - row := s.db.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, story_id, acceptance_criteria, checker_for_task_id, checker_report FROM tasks WHERE id = ?`, id) + row := s.db.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id) return scanTask(row) } // ListTasks returns tasks matching the given filter. func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) { - query := `SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, story_id, acceptance_criteria, checker_for_task_id, checker_report FROM tasks WHERE 1=1` + query := `SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE 1=1` var args []interface{} if filter.State != "" { @@ -272,7 +256,7 @@ func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) { // ListSubtasks returns all tasks whose parent_task_id matches the given ID. func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) { - rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, story_id, acceptance_criteria, checker_for_task_id, checker_report FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID) + rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID) if err != nil { return nil, err } @@ -342,7 +326,7 @@ func (s *DB) ResetTaskForRetry(id string) (*task.Task, error) { } defer tx.Rollback() //nolint:errcheck - t, err := scanTask(tx.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, story_id, acceptance_criteria, checker_for_task_id, checker_report FROM tasks WHERE id = ?`, id)) + t, err := scanTask(tx.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id)) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("task %q not found", id) @@ -987,24 +971,6 @@ func (s *DB) UpdateTaskSummary(taskID, summary string) error { return tx.Commit() } -// UpdateTaskCheckerReport sets the checker_report field on a task. -func (s *DB) UpdateTaskCheckerReport(id, report string) error { - now := time.Now().UTC() - _, err := s.db.Exec(`UPDATE tasks SET checker_report = ?, updated_at = ? WHERE id = ?`, report, now, id) - return err -} - -// GetCheckerTask returns the checker task for the given checked task ID, -// or nil if no checker task exists. -func (s *DB) GetCheckerTask(checkedTaskID string) (*task.Task, error) { - row := s.db.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, story_id, acceptance_criteria, checker_for_task_id, checker_report FROM tasks WHERE checker_for_task_id = ? LIMIT 1`, checkedTaskID) - t, err := scanTask(row) - if err == sql.ErrNoRows { - return nil, nil - } - return t, err -} - // AppendTaskInteraction appends a Q&A interaction to the task's interaction history. func (s *DB) AppendTaskInteraction(taskID string, interaction task.Interaction) error { tx, err := s.db.Begin() @@ -1102,17 +1068,12 @@ func scanTask(row scanner) (*task.Task, error) { questionJSON sql.NullString summary sql.NullString interactionsJSON sql.NullString - storyID sql.NullString - acceptanceCriteria sql.NullString - checkerForTaskID sql.NullString - checkerReport sql.NullString ) err := row.Scan( &t.ID, &t.Name, &t.Description, &elaborationInput, &project, &repositoryURL, &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON, &parentTaskID, &state, &t.CreatedAt, &t.UpdatedAt, - &rejectionComment, &questionJSON, &summary, &interactionsJSON, &storyID, - &acceptanceCriteria, &checkerForTaskID, &checkerReport, + &rejectionComment, &questionJSON, &summary, &interactionsJSON, ) t.ParentTaskID = parentTaskID.String t.ElaborationInput = elaborationInput.String @@ -1121,10 +1082,6 @@ func scanTask(row scanner) (*task.Task, error) { t.RejectionComment = rejectionComment.String t.QuestionJSON = questionJSON.String t.Summary = summary.String - t.StoryID = storyID.String - t.AcceptanceCriteria = acceptanceCriteria.String - t.CheckerForTaskID = checkerForTaskID.String - t.CheckerReport = checkerReport.String if err != nil { return nil, err } @@ -1396,73 +1353,3 @@ func (s *DB) UpsertProject(p *task.Project) error { return err } -// CreateStory inserts a new story. -func (s *DB) CreateStory(st *task.Story) error { - now := time.Now().UTC() - _, err := s.db.Exec( - `INSERT INTO stories (id, name, project_id, branch_name, deploy_config, validation_json, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - st.ID, st.Name, st.ProjectID, st.BranchName, st.DeployConfig, st.ValidationJSON, string(st.Status), now, now, - ) - return err -} - -// GetStory retrieves a story by ID. -func (s *DB) GetStory(id string) (*task.Story, error) { - row := s.db.QueryRow(`SELECT id, name, project_id, branch_name, deploy_config, validation_json, status, created_at, updated_at FROM stories WHERE id = ?`, id) - st := &task.Story{} - var status string - if err := row.Scan(&st.ID, &st.Name, &st.ProjectID, &st.BranchName, &st.DeployConfig, &st.ValidationJSON, &status, &st.CreatedAt, &st.UpdatedAt); err != nil { - return nil, err - } - st.Status = task.StoryState(status) - return st, nil -} - -// ListStories returns all stories ordered by creation time descending. -func (s *DB) ListStories() ([]*task.Story, error) { - rows, err := s.db.Query(`SELECT id, name, project_id, branch_name, deploy_config, validation_json, status, created_at, updated_at FROM stories ORDER BY created_at DESC`) - if err != nil { - return nil, err - } - defer rows.Close() - var stories []*task.Story - for rows.Next() { - st := &task.Story{} - var status string - if err := rows.Scan(&st.ID, &st.Name, &st.ProjectID, &st.BranchName, &st.DeployConfig, &st.ValidationJSON, &status, &st.CreatedAt, &st.UpdatedAt); err != nil { - return nil, err - } - st.Status = task.StoryState(status) - stories = append(stories, st) - } - return stories, rows.Err() -} - -// UpdateStoryStatus updates the status of a story. -func (s *DB) UpdateStoryStatus(id string, status task.StoryState) error { - now := time.Now().UTC() - _, err := s.db.Exec(`UPDATE stories SET status = ?, updated_at = ? WHERE id = ?`, string(status), now, id) - return err -} - -// ListTasksByStory returns all tasks associated with a story, ordered by creation time ascending. -func (s *DB) ListTasksByStory(storyID string) ([]*task.Task, error) { - rows, err := s.db.Query( - `SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, story_id, acceptance_criteria, checker_for_task_id, checker_report FROM tasks WHERE story_id = ? ORDER BY created_at ASC`, - storyID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var tasks []*task.Task - for rows.Next() { - t, err := scanTaskRows(rows) - if err != nil { - return nil, err - } - tasks = append(tasks, t) - } - return tasks, rows.Err() -} diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 4d744a4..09bbdfc 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -1256,187 +1256,4 @@ func TestUpdateProject(t *testing.T) { } } -func TestCreateStory(t *testing.T) { - db := testDB(t) - st := &task.Story{ - ID: "story-1", - Name: "My Story", - Status: task.StoryPending, - } - if err := db.CreateStory(st); err != nil { - t.Fatalf("CreateStory: %v", err) - } -} - -func TestGetStory(t *testing.T) { - db := testDB(t) - st := &task.Story{ - ID: "story-2", - Name: "Get Story", - ProjectID: "proj-1", - Status: task.StoryPending, - } - if err := db.CreateStory(st); err != nil { - t.Fatalf("CreateStory: %v", err) - } - got, err := db.GetStory("story-2") - if err != nil { - t.Fatalf("GetStory: %v", err) - } - if got.Name != "Get Story" { - t.Errorf("Name: want 'Get Story', got %q", got.Name) - } - if got.ProjectID != "proj-1" { - t.Errorf("ProjectID: want 'proj-1', got %q", got.ProjectID) - } - if got.Status != task.StoryPending { - t.Errorf("Status: want PENDING, got %q", got.Status) - } -} - -func TestListStories(t *testing.T) { - db := testDB(t) - for _, name := range []string{"A", "B", "C"} { - if err := db.CreateStory(&task.Story{ID: name, Name: name, Status: task.StoryPending}); err != nil { - t.Fatalf("CreateStory %s: %v", name, err) - } - } - stories, err := db.ListStories() - if err != nil { - t.Fatalf("ListStories: %v", err) - } - if len(stories) != 3 { - t.Errorf("want 3 stories, got %d", len(stories)) - } -} - -func TestUpdateStoryStatus(t *testing.T) { - db := testDB(t) - st := &task.Story{ID: "story-upd", Name: "Upd", Status: task.StoryPending} - if err := db.CreateStory(st); err != nil { - t.Fatalf("CreateStory: %v", err) - } - if err := db.UpdateStoryStatus("story-upd", task.StoryInProgress); err != nil { - t.Fatalf("UpdateStoryStatus: %v", err) - } - got, _ := db.GetStory("story-upd") - if got.Status != task.StoryInProgress { - t.Errorf("Status: want IN_PROGRESS, got %q", got.Status) - } -} - -func TestListTasksByStory(t *testing.T) { - db := testDB(t) - now := time.Now().UTC() - - if err := db.CreateStory(&task.Story{ID: "story-tasks", Name: "S", Status: task.StoryPending}); err != nil { - t.Fatalf("CreateStory: %v", err) - } - - makeTask := func(id string) *task.Task { - return &task.Task{ - ID: id, - Name: id, - StoryID: "story-tasks", - Agent: task.AgentConfig{Type: "claude"}, - Priority: task.PriorityNormal, - Tags: []string{}, - DependsOn: []string{}, - Retry: task.RetryConfig{MaxAttempts: 1}, - State: task.StatePending, - CreatedAt: now, - UpdatedAt: now, - } - } - - if err := db.CreateTask(makeTask("t1")); err != nil { - t.Fatal(err) - } - if err := db.CreateTask(makeTask("t2")); err != nil { - t.Fatal(err) - } - - tasks, err := db.ListTasksByStory("story-tasks") - if err != nil { - t.Fatalf("ListTasksByStory: %v", err) - } - if len(tasks) != 2 { - t.Errorf("want 2 tasks, got %d", len(tasks)) - } - for _, tk := range tasks { - if tk.StoryID != "story-tasks" { - t.Errorf("task %s: StoryID want 'story-tasks', got %q", tk.ID, tk.StoryID) - } - } -} - -func TestUpdateTaskCheckerReport(t *testing.T) { - db := testDB(t) - tk := &task.Task{ - ID: "cr-1", Name: "orig", RepositoryURL: "https://github.com/x/y", - Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, - Priority: task.PriorityNormal, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, - Tags: []string{}, DependsOn: []string{}, - State: task.StatePending, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), - } - if err := db.CreateTask(tk); err != nil { - t.Fatalf("CreateTask: %v", err) - } - if err := db.UpdateTaskCheckerReport("cr-1", "Tests failed: missing endpoint"); err != nil { - t.Fatalf("UpdateTaskCheckerReport: %v", err) - } - got, err := db.GetTask("cr-1") - if err != nil { - t.Fatalf("GetTask: %v", err) - } - if got.CheckerReport != "Tests failed: missing endpoint" { - t.Errorf("expected checker report, got %q", got.CheckerReport) - } -} - -func TestGetCheckerTask(t *testing.T) { - db := testDB(t) - checked := &task.Task{ - ID: "chk-orig", Name: "orig", RepositoryURL: "https://github.com/x/y", - Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, - Priority: task.PriorityNormal, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, - Tags: []string{}, DependsOn: []string{}, - State: task.StatePending, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), - } - if err := db.CreateTask(checked); err != nil { - t.Fatalf("CreateTask checked: %v", err) - } - checker := &task.Task{ - ID: "chk-checker", Name: "Check: orig", CheckerForTaskID: "chk-orig", - RepositoryURL: "https://github.com/x/y", - Agent: task.AgentConfig{Type: "claude", Instructions: "validate"}, - Priority: task.PriorityNormal, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, - Tags: []string{}, DependsOn: []string{}, - State: task.StatePending, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), - } - if err := db.CreateTask(checker); err != nil { - t.Fatalf("CreateTask checker: %v", err) - } - - // Should find the checker task. - got, err := db.GetCheckerTask("chk-orig") - if err != nil { - t.Fatalf("GetCheckerTask: %v", err) - } - if got == nil || got.ID != "chk-checker" { - t.Errorf("expected checker task ID chk-checker, got %v", got) - } - - // Should return nil when no checker exists. - none, err := db.GetCheckerTask("nonexistent") - if err != nil { - t.Fatalf("GetCheckerTask nonexistent: %v", err) - } - if none != nil { - t.Errorf("expected nil for task with no checker, got %v", none) - } -} diff --git a/internal/task/story.go b/internal/task/story.go deleted file mode 100644 index 536bda1..0000000 --- a/internal/task/story.go +++ /dev/null @@ -1,41 +0,0 @@ -package task - -import "time" - -type StoryState string - -const ( - StoryPending StoryState = "PENDING" - StoryInProgress StoryState = "IN_PROGRESS" - StoryShippable StoryState = "SHIPPABLE" - StoryDeployed StoryState = "DEPLOYED" - StoryValidating StoryState = "VALIDATING" - StoryReviewReady StoryState = "REVIEW_READY" - StoryNeedsFix StoryState = "NEEDS_FIX" -) - -type Story struct { - ID string `json:"id"` - Name string `json:"name"` - ProjectID string `json:"project_id"` - BranchName string `json:"branch_name"` - DeployConfig string `json:"deploy_config"` - ValidationJSON string `json:"validation_json"` - Status StoryState `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -var validStoryTransitions = map[StoryState]map[StoryState]bool{ - StoryPending: {StoryInProgress: true}, - StoryInProgress: {StoryShippable: true, StoryNeedsFix: true}, - StoryShippable: {StoryDeployed: true}, - StoryDeployed: {StoryValidating: true}, - StoryValidating: {StoryReviewReady: true, StoryNeedsFix: true}, - StoryReviewReady: {}, - StoryNeedsFix: {StoryInProgress: true}, -} - -func ValidStoryTransition(from, to StoryState) bool { - return validStoryTransitions[from][to] -} diff --git a/internal/task/story_test.go b/internal/task/story_test.go deleted file mode 100644 index 38d0290..0000000 --- a/internal/task/story_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package task - -import "testing" - -func TestValidStoryTransition_Valid(t *testing.T) { - cases := []struct { - from StoryState - to StoryState - }{ - {StoryPending, StoryInProgress}, - {StoryInProgress, StoryShippable}, - {StoryInProgress, StoryNeedsFix}, - {StoryNeedsFix, StoryInProgress}, - {StoryShippable, StoryDeployed}, - {StoryDeployed, StoryValidating}, - {StoryValidating, StoryReviewReady}, - {StoryValidating, StoryNeedsFix}, - } - for _, tc := range cases { - if !ValidStoryTransition(tc.from, tc.to) { - t.Errorf("expected valid transition %s → %s", tc.from, tc.to) - } - } -} - -func TestValidStoryTransition_Invalid(t *testing.T) { - cases := []struct { - from StoryState - to StoryState - }{ - {StoryPending, StoryDeployed}, - {StoryReviewReady, StoryPending}, - {StoryReviewReady, StoryInProgress}, - {StoryReviewReady, StoryShippable}, - {StoryShippable, StoryPending}, - } - for _, tc := range cases { - if ValidStoryTransition(tc.from, tc.to) { - t.Errorf("expected invalid transition %s → %s", tc.from, tc.to) - } - } -} diff --git a/internal/task/task.go b/internal/task/task.go index eeac49e..7092ce8 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -88,11 +88,7 @@ type Task struct { Priority Priority `yaml:"priority" json:"priority"` Tags []string `yaml:"tags" json:"tags"` DependsOn []string `yaml:"depends_on" json:"depends_on"` - StoryID string `yaml:"-" json:"story_id,omitempty"` BranchName string `yaml:"-" json:"branch_name,omitempty"` - AcceptanceCriteria string `yaml:"-" json:"acceptance_criteria,omitempty"` - CheckerForTaskID string `yaml:"-" json:"checker_for_task_id,omitempty"` - CheckerReport string `yaml:"-" json:"checker_report,omitempty"` State State `yaml:"-" json:"state"` RejectionComment string `yaml:"-" json:"rejection_comment,omitempty"` QuestionJSON string `yaml:"-" json:"question,omitempty"` |
