diff options
Diffstat (limited to 'internal/scheduler/story_orchestrator_test.go')
| -rw-r--r-- | internal/scheduler/story_orchestrator_test.go | 118 |
1 files changed, 118 insertions, 0 deletions
diff --git a/internal/scheduler/story_orchestrator_test.go b/internal/scheduler/story_orchestrator_test.go index 08bd155..34e828a 100644 --- a/internal/scheduler/story_orchestrator_test.go +++ b/internal/scheduler/story_orchestrator_test.go @@ -735,6 +735,15 @@ func TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady(t *t arb := arbitrations[0] store.setTaskState(arb.ID, task.StateCompleted) store.setTaskSummary(arb.ID, "ship it") + // finalizeArbitration is fail-closed (no verdict = rejection): an + // explicit approved=true verdict is required to reach REVIEW_READY. + payload0, _ := json.Marshal(struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + }{Approved: true, Reasoning: "meets criteria"}) + if err := store.CreateEvent(&event.Event{TaskID: arb.ID, Kind: event.KindVerdictReported, Actor: event.ActorAgent, Payload: payload0}); err != nil { + t.Fatalf("seed verdict event: %v", err) + } // Tick 2: arbitration is now COMPLETED. orch.Tick(context.Background()) @@ -1236,6 +1245,15 @@ func TestStoryOrchestrator_AutoAcceptsReadyArbitration(t *testing.T) { // task — without ever calling POST /api/tasks/{id}/accept. store.setTaskState(arb.ID, task.StateReady) store.setTaskSummary(arb.ID, "approved") + // finalizeArbitration is fail-closed (no verdict = rejection): an + // explicit approved=true verdict is required to reach REVIEW_READY. + payload, _ := json.Marshal(struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + }{Approved: true, Reasoning: "meets criteria"}) + if err := store.CreateEvent(&event.Event{TaskID: arb.ID, Kind: event.KindVerdictReported, Actor: event.ActorAgent, Payload: payload}); err != nil { + t.Fatalf("seed verdict event: %v", err) + } // Tick 2: orchestrator must auto-accept READY -> COMPLETED itself. orch.Tick(context.Background()) @@ -1426,6 +1444,16 @@ func TestStoryOrchestrator_EndToEnd(t *testing.T) { // Arbitration's execution succeeds (READY, not COMPLETED). store.setTaskState(arb.ID, task.StateReady) store.setTaskSummary(arb.ID, "approved") + // finalizeArbitration is fail-closed (no verdict = rejection): an + // explicit approved=true verdict is required for the story to reach + // REVIEW_READY and root to be promoted to COMPLETED below. + arbPayload, _ := json.Marshal(struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + }{Approved: true, Reasoning: "meets criteria"}) + if err := store.CreateEvent(&event.Event{TaskID: arb.ID, Kind: event.KindVerdictReported, Actor: event.ActorAgent, Payload: arbPayload}); err != nil { + t.Fatalf("seed verdict event: %v", err) + } orch.Tick(context.Background()) arbAfter, err := store.GetTask(arb.ID) @@ -1921,3 +1949,93 @@ func TestStoryOrchestrator_EndToEnd_NestedSubtask(t *testing.T) { t.Fatalf("expected REVIEW_READY once root's own arbitration approves it, got %q", statusOf()) } } + +// TestStoryOrchestrator_FinalizeArbitration_RootNoVerdict_TreatedAsRejection +// proves the fail-closed default added 2026-07-11: an arbitration task that +// completes without ever calling report_verdict (no KindVerdictReported +// event at all) must be treated as a REJECTION, not a silent approval. This +// replaces the earlier "no verdict defaults to approval" behavior, which a +// live production run demonstrated could ship work an evaluator had already +// flagged as factually wrong, entirely undetected. +func TestStoryOrchestrator_FinalizeArbitration_RootNoVerdict_TreatedAsRejection(t *testing.T) { + store := newFakeStoryStore() + root := builderTask("no-verdict-root", task.StateReady) + store.tasks[root.ID] = root + st := newStoryWithRoot("no-verdict-story", root.ID, "VALIDATING") + store.stories[st.ID] = st + + arb := &task.Task{ + ID: "no-verdict-arb", + Name: "Arbitration", + Agent: task.AgentConfig{Role: arbitrationRole}, + State: task.StateCompleted, + Summary: "", // never called report_summary or report_verdict + } + store.tasks[arb.ID] = arb + // Deliberately no KindVerdictReported event seeded. + + pool := &fakePool{} + orch := &StoryOrchestrator{Store: store, Pool: pool} + orch.finalizeArbitration(context.Background(), st, root, arb) + + gotRoot, err := store.GetTask(root.ID) + if err != nil { + t.Fatal(err) + } + if gotRoot.State != task.StateReady { + t.Errorf("root State = %v, want unchanged READY (no verdict must not promote to COMPLETED)", gotRoot.State) + } + + stories, _ := store.ListStories(storage.StoryFilter{}) + var gotStory *story.Story + for _, s := range stories { + if s.ID == st.ID { + gotStory = s + } + } + if gotStory.Status != "NEEDS_FIX" { + t.Errorf("story Status = %q, want NEEDS_FIX (no verdict must be treated as rejection, not approval)", gotStory.Status) + } +} + +// TestStoryOrchestrator_FinalizeArbitration_NestedNoVerdict_SpawnsFixAttempt +// mirrors the root case above for a nested position: no structured verdict +// reported must spawn a fix-attempt directly, exactly like an explicit +// rejection would, not silently promote the node to COMPLETED. +func TestStoryOrchestrator_FinalizeArbitration_NestedNoVerdict_SpawnsFixAttempt(t *testing.T) { + store := newFakeStoryStore() + root := builderTask("no-verdict-nested-root", task.StateBlocked) + store.tasks[root.ID] = root + st := newStoryWithRoot("no-verdict-nested-story", root.ID, "IN_PROGRESS") + store.stories[st.ID] = st + + nested := builderTask("no-verdict-nested-node", task.StateReady) + nested.ParentTaskID = root.ID + store.tasks[nested.ID] = nested + + arb := &task.Task{ + ID: "no-verdict-nested-arb", + Name: "Arbitration", + Agent: task.AgentConfig{Role: arbitrationRole}, + State: task.StateCompleted, + } + store.tasks[arb.ID] = arb + // Deliberately no KindVerdictReported event seeded. + + pool := &fakePool{} + orch := &StoryOrchestrator{Store: store, Pool: pool} + orch.finalizeArbitration(context.Background(), st, nested, arb) + + got, err := store.GetTask(nested.ID) + if err != nil { + t.Fatal(err) + } + if got.State != task.StateReady { + t.Errorf("nested node State = %v, want unchanged READY (no verdict must not promote to COMPLETED)", got.State) + } + + fixAttempts := store.dependentsWithRole(nested.ID, "builder") + if len(fixAttempts) != 1 { + t.Fatalf("expected exactly 1 fix-attempt task spawned for a no-verdict arbitration, got %d", len(fixAttempts)) + } +} |
