summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/executor/executor.go126
-rw-r--r--internal/executor/executor_test.go158
2 files changed, 283 insertions, 1 deletions
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 8dfb196..4183ab0 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "os/exec"
"path/filepath"
"strings"
"sync"
@@ -36,6 +37,7 @@ type Store interface {
GetStory(id string) (*task.Story, error)
ListTasksByStory(storyID string) ([]*task.Task, error)
UpdateStoryStatus(id string, status task.StoryState) error
+ CreateTask(t *task.Task) error
}
// LogPather is an optional interface runners can implement to provide the log
@@ -380,6 +382,19 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage.
}
}
}
+ 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
@@ -409,7 +424,19 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage.
p.maybeUnblockParent(t.ParentTaskID)
}
if t.StoryID != "" {
- go p.checkStoryCompletion(ctx, 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)
+ }
+ }()
}
}
@@ -463,6 +490,103 @@ func (p *Pool) checkStoryCompletion(ctx context.Context, storyID string) {
return
}
p.logger.Info("story transitioned to SHIPPABLE", "storyID", storyID)
+ go p.triggerStoryDeploy(ctx, storyID)
+}
+
+// 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
+ }
+ 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)
+ }
}
// UndrainingAgent resets the drain state and failure counter for the given agent type.
diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go
index b93e819..44fa7b5 100644
--- a/internal/executor/executor_test.go
+++ b/internal/executor/executor_test.go
@@ -1061,6 +1061,7 @@ func (m *minimalMockStore) GetProject(_ string) (*task.Project, error)
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) lastStateUpdate() (string, task.State, bool) {
m.mu.Lock()
@@ -1757,3 +1758,160 @@ func TestPool_Undrain_ResumesExecution(t *testing.T) {
t.Fatal("timed out waiting for task after undrain")
}
}
+
+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 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)
+ }
+}
+
+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)
+ }
+}