diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 7250538..15def1c 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -843,3 +843,78 @@ func TestTasksViewAbsentOutsidePlanning(t *testing.T) { t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks) } } + +// TestStaleTasksFetchDiscardedAfterLeavingPlanning exercises the +// "left planning" arm of the discard guard in startTasksFetchLocked. +// +// Sequence: +// 1. Enter planning with a gated provider (fetch in flight, blocked). +// 2. StartManualCommitment moves Planning -> Active, leaving planning. +// 3. End the session to return to Locked. +// 4. Install a second (ungated) provider and re-enter planning. +// 5. Wait for the fresh fetch to reach "ready" with the new provider's list. +// 6. Release the gate — the stale goroutine from step 1 now runs and must be +// discarded (runtimeState != Planning at commit time). +// 7. Assert the stale result did not clobber the fresh tasks list. +func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) { + c, _ := newTestController(t) + + // Step 1: gated provider whose result ("STALE") must never surface. + staleGate := make(chan struct{}) + stale := &fakeProvider{ + list: []tasks.Task{{ID: "s1", Title: "STALE"}}, + gate: staleGate, + } + c.SetTasks(stale) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + // Wait until the goroutine has started and is pending (blocked on gate). + waitTasksStatus(t, c, "pending") + + // Step 2: leave planning via a commitment — the stale fetch is still blocked. + if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil); err != nil { + t.Fatalf("start commitment: %v", err) + } + if c.State().RuntimeState != domain.RuntimeActive { + t.Fatalf("expected Active after StartManualCommitment") + } + + // Step 3: end the session to return to Locked. + if err := c.Complete(); err != nil { + t.Fatalf("complete: %v", err) + } + if err := c.End(); err != nil { + t.Fatalf("end: %v", err) + } + + // Step 4: fresh provider whose result ("FRESH") is the expected state. + fresh := &fakeProvider{list: []tasks.Task{{ID: "f1", Title: "FRESH"}}} + c.SetTasks(fresh) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("second enter planning: %v", err) + } + + // Step 5: wait for the fresh fetch to complete and be visible. + st := waitTasksStatus(t, c, "ready") + if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "FRESH" { + t.Fatalf("expected FRESH tasks after second planning entry, got %+v", st.Tasks) + } + + // Step 6: release the stale goroutine — it must detect runtimeState != Planning + // (we are now in Planning again, but tasksGen has moved on) and discard. + close(staleGate) + time.Sleep(50 * time.Millisecond) + + // Step 7: the fresh list must still be intact; STALE must not have landed. + st = c.State() + if st.Tasks == nil { + t.Fatalf("tasks view should still be present in planning") + } + if st.Tasks.Status != "ready" { + t.Fatalf("tasks status should still be ready, got %q", st.Tasks.Status) + } + if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "FRESH" { + t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks) + } +}