diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 16d1b62..119a809 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -949,6 +949,83 @@ func TestTasksViewAbsentOutsidePlanning(t *testing.T) { } } +// TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning exercises the +// "left planning" arm of the discard guard in startKnowledgeFetchLocked: +// a slow knowledge load that returns after the user has left planning must +// not clobber the fresh state produced by a later planning entry. +// +// Sequence: +// 1. Enter planning with a gated source (load in flight, blocked on the gate). +// 2. StartManualCommitment moves Planning -> Active, leaving planning. +// 3. Complete + End the session to return to Locked. +// 4. Install a second (ungated) source and re-enter planning. +// 5. Wait for the fresh load to reach "ready" with the new source's profile. +// 6. Release the gate — the stale goroutine from step 1 now runs and must be +// discarded (knowledgeGen has moved on / runtimeState differs at commit). +// 7. Assert, through the now-visible Planning-state projection, that the stale +// result did not clobber the fresh knowledge view. The "/stale" vs "/fresh" +// Path distinguishes the two (both texts are 5 chars, so Chars cannot). +func TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning(t *testing.T) { + c, _ := newTestController(t) + + // Step 1: gated source whose result ("/stale") must never surface. + staleGate := make(chan struct{}) + stale := &fakeSource{ + profile: knowledge.Profile{Text: "STALE", Path: "/stale"}, + gate: staleGate, + } + c.SetKnowledge(stale) + if err := c.EnterPlanning(); err != nil { // launches the gated load (gen 1) + t.Fatalf("enter planning: %v", err) + } + + // Step 2: leave planning via a commitment — the stale load is still blocked. + if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); 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, ungated source whose result ("/fresh") is the expected state. + fresh := &fakeSource{profile: knowledge.Profile{Text: "FRESH", Path: "/fresh"}} + c.SetKnowledge(fresh) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("second enter planning: %v", err) + } + + // Step 5: wait for the fresh load to complete and be visible. + st := waitKnowledgeStatus(t, c, "ready") + if st.Knowledge.Path != "/fresh" || st.Knowledge.Chars != len("FRESH") { + t.Fatalf("expected FRESH knowledge after second planning entry, got %+v", st.Knowledge) + } + + // Step 6: release the stale goroutine — it must detect that its generation is + // stale and discard rather than overwrite the fresh view. + close(staleGate) + time.Sleep(50 * time.Millisecond) + + // Step 7: the fresh view must still be intact; "/stale" must not have landed. + st = c.State() + if st.Knowledge == nil { + t.Fatalf("knowledge view should still be present in planning") + } + if st.Knowledge.Status != "ready" { + t.Fatalf("knowledge status should still be ready, got %q", st.Knowledge.Status) + } + if st.Knowledge.Path != "/fresh" || st.Knowledge.Chars != len("FRESH") { + t.Fatalf("stale knowledge fetch clobbered the fresh view: %+v", st.Knowledge) + } +} + // TestStaleTasksFetchDiscardedAfterLeavingPlanning exercises the // "left planning" arm of the discard guard in startTasksFetchLocked. // @@ -1027,9 +1104,13 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) { type fakeSource struct { profile knowledge.Profile err error + gate chan struct{} // if non-nil, Load blocks until it receives } func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { + if f.gate != nil { + <-f.gate + } return f.profile, f.err }