summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-06-05 09:21:32 +0000
committerClaudomator Agent <agent@claudomator>2026-06-05 09:21:32 +0000
commit0eb0b79396663c4901597becc6857a4cd795c58e (patch)
tree3532c82aa3f7c6deeed03f143d1c360d7cf6cc47
parentfa3ed5220a28f326d443726233c37e1a7df0ced5 (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>
-rw-r--r--internal/api/agentmcp_endpoint_test.go4
-rw-r--r--internal/api/elaborate.go244
-rw-r--r--internal/api/server.go15
-rw-r--r--internal/api/server_test.go51
-rw-r--r--internal/api/stories.go378
-rw-r--r--internal/api/stories_test.go351
-rw-r--r--internal/api/taskops.go3
-rw-r--r--internal/api/validate.go18
-rw-r--r--internal/api/webhook_test.go16
-rw-r--r--internal/cli/serve.go2
-rw-r--r--internal/executor/container.go73
-rw-r--r--internal/executor/container_test.go41
-rw-r--r--internal/executor/executor.go353
-rw-r--r--internal/executor/executor_test.go548
-rw-r--r--internal/storage/db.go129
-rw-r--r--internal/storage/db_test.go183
-rw-r--r--internal/task/story.go41
-rw-r--r--internal/task/story_test.go42
-rw-r--r--internal/task/task.go4
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"`