diff --git a/docs/superpowers/plans/2026-06-01-m9-tame-session.md b/docs/superpowers/plans/2026-06-01-m9-tame-session.md new file mode 100644 index 0000000..52fddd8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-m9-tame-session.md @@ -0,0 +1,680 @@ +# M9 — Tame `session.go` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the 1278-line `internal/session/session.go` into five focused files and consolidate the 4×-duplicated async-fetch boilerplate into one helper, with **zero behavior change**. + +**Architecture:** Everything stays in `package session` (one `Controller` behind one `sync.Mutex` — sub-packages would force private state to be exported). Phase 1 moves declarations into focused files with no logic edits. Phase 2 adds a `runFetchAsync(timeout, fetch, stale, apply)` helper and migrates the four async roles to it one at a time. The existing `session_test.go` + `web_test.go` suites are the safety net; the contract is **green-to-green under `-race`** after every commit. + +**Tech Stack:** Go 1.26, stdlib `testing`, `go test -race`. No new dependencies. + +--- + +## Critical rules for every task + +- **No behavior change.** Move and re-shape code; never alter what it does. No exported symbol is renamed, removed, or has its signature changed. +- **Locate declarations by name** (`grep -n`), not by the line numbers in this plan — line numbers shift as earlier tasks move code. The line numbers here are hints from the pre-refactor file. +- **Move bodies verbatim.** In Phase 1, cut each declaration exactly as written and paste it into the new file. Do not edit logic. +- **Resolve imports with the compiler.** After moving declarations, run `go build ./internal/session/`. Add the imports the new file needs; delete imports the compiler reports as now-unused in `session.go`. If `goimports` is available (`go run golang.org/x/tools/cmd/goimports@latest -w internal/session/`), it does both automatically. The "expected imports" lists below are guidance, not gospel. +- **Green gate after every commit:** `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`. All must pass. Never commit red. +- All commands run from the repo root `/home/felixm/dev/antidrift`. Commit messages end with the `Co-Authored-By` trailer used in this repo. + +--- + +## File Structure (target) + +All `package session`: + +- `session.go` — `Controller` struct, `New`, `SetClock`/`SetOnChange`/`notify`, `State`/`Deadline`, `persistLocked`, lifecycle transitions (`EnterPlanning`, `StartManualCommitment`, `Complete`/`Expire`/`enterReview`, `End`, `buildSummaryLocked`), `ErrNotPlanning`/`ErrNotActive`, the `unavailableTitle`/`sessionRetention` consts, `AllowedClassesForTest`/`EnforcementLevelForTest`. +- `views.go` — the 11 `*View` types, the `State` type, `stateLocked`, `bucketViews`. +- `roles.go` — `runFetchAsync` + coach/tasks/knowledge/reflection (`Set*`, `start*FetchLocked`/`RequestCoach`, `composedGroundingLocked`, `coachErrorMessage`, `buildReflectionFinishedLocked`, `buildReflectionHistory`) + their timeout/status consts. +- `drift.go` — `RecordWindow`, `evaluateDriftLocked`, `maybeNudgeLocked`, `enforceActionLocked`, `applyVerdictLocked`, `resetDriftLocked`, `OnTask`, `Refocus`, `recordTitleLocked`, `commitmentLineLocked`, `Set{DriftJudge,Guard,Nudge}`, `recentTitlesForTest`, the drift/nudge/enforce consts (`driftDebounce`/`driftTimeout`/`enforceTimeout`/`nudgeDebounce`/`nudgeTimeout`, the `drift*` status consts, `recentTitlesMax`). +- `stats.go` — `EvidenceStats`, `bucketKey`, `applyEvent`, `replayStats`, `keyFor`, `focusEvent`, `snapFromEvent`. + +--- + +## Task 1: Close the coverage gap — knowledge stale-discard test + +The audit found one gap: knowledge has no dedicated stale-discard test, while coach/tasks/reflection do. Add one **before** refactoring so the shared helper's stale path is covered for every role. This is a **characterization test** — it documents existing behavior and must pass against the current (un-refactored) code. + +**Files:** +- Modify: `internal/session/session_test.go` + +- [ ] **Step 1: Add a gate channel to `fakeSource`** + +The knowledge test double `fakeSource` (defined around line 1027 of `internal/session/session_test.go`) currently has no gate, so a load can't be held in flight. Add one, mirroring `fakeProvider` (line ~885) and `fakeCoach` (line ~251). Replace: + +```go +type fakeSource struct { + profile knowledge.Profile + err error +} + +func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { + return f.profile, f.err +} +``` + +with: + +```go +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 +} +``` + +This is behavior-preserving for the existing knowledge tests (they construct `fakeSource` without a `gate`, so it stays nil and `Load` never blocks). + +- [ ] **Step 2: Write the characterization test** + +Add to `internal/session/session_test.go`, mirroring `TestStaleTasksFetchDiscardedAfterLeavingPlanning` (line ~964). The `State.Knowledge` field is a `*KnowledgeView` accessed as `st.Knowledge.Path`/`st.Knowledge.Chars` in the existing knowledge tests: + +```go +// 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 fresh state. +func TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning(t *testing.T) { + c, _ := newTestController(t) + 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.Fatal(err) + } + // Leave planning while the gen-1 load is still blocked on the gate. + if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil { + t.Fatal(err) + } + + close(staleGate) // release gen 1; it must be discarded (left planning) + // Give the released goroutine time to attempt its commit. + time.Sleep(50 * time.Millisecond) + + st := c.State() + if st.Knowledge != nil && st.Knowledge.Chars != 0 { + t.Fatalf("stale knowledge fetch clobbered state: %+v", st.Knowledge) + } +} +``` + +- [ ] **Step 3: Run it — expect PASS (characterizes current behavior)** + +Run: `go test -race ./internal/session/ -run TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning -v` +Expected: PASS. (It documents existing behavior; if it fails, the test is wrong or the fake's gate is mis-wired — fix the test, not production code.) + +- [ ] **Step 4: Full green gate** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/session/session_test.go +git commit -m "Add knowledge stale-discard characterization test + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Extract `views.go` (file split, no logic change) + +**Files:** +- Create: `internal/session/views.go` +- Modify: `internal/session/session.go` + +- [ ] **Step 1: Create the new file with the package clause** + +Create `internal/session/views.go`: + +```go +package session +``` + +- [ ] **Step 2: Move the declarations verbatim** + +Cut these declarations from `session.go` (locate each by name with `grep -n 'type CommitmentView' internal/session/session.go` etc.) and paste them, unedited, into `views.go`: + +- the view types, in this order: `CommitmentView`, `ProposalView`, `DriftView`, `CoachView`, `TaskView`, `TasksView`, `KnowledgeView`, `ReflectionView`, `WindowView`, `BucketView`, `EvidenceView`, `State` +- the method `func (c *Controller) stateLocked() State { ... }` +- the function `func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { ... }` + +Do not change a single line of their bodies. + +- [ ] **Step 3: Fix imports** + +Run: `go build ./internal/session/` +`views.go` is expected to need: `sort`, `time`, `antidrift/internal/domain`, `antidrift/internal/evidence`, `antidrift/internal/tasks`. Add whatever the compiler reports as undefined, and remove from `session.go` any import the compiler now reports as unused. (Or run `goimports -w internal/session/` to do both.) + +- [ ] **Step 4: Green gate** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS (pure code motion — behavior is identical). + +- [ ] **Step 5: Commit** + +```bash +git add internal/session/views.go internal/session/session.go +git commit -m "Split session views into views.go + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Extract `stats.go` (file split, no logic change) + +**Files:** +- Create: `internal/session/stats.go` +- Modify: `internal/session/session.go` + +- [ ] **Step 1: Create the new file** + +Create `internal/session/stats.go`: + +```go +package session +``` + +- [ ] **Step 2: Move the declarations verbatim** + +Cut these from `session.go` and paste unedited into `stats.go`: + +- `type bucketKey struct{ Class, Title string }` +- `type EvidenceStats struct { ... }` +- `func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot)` +- `func (c *Controller) replayStats(sessionID string)` +- `func keyFor(snap evidence.WindowSnapshot) bucketKey` +- `func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent` +- `func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot` + +- [ ] **Step 3: Fix imports** + +Run: `go build ./internal/session/` +`stats.go` is expected to need: `time`, `antidrift/internal/evidence`, `antidrift/internal/store`. Add/remove per the compiler (or `goimports -w`). + +- [ ] **Step 4: Green gate** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/session/stats.go internal/session/session.go +git commit -m "Split evidence-stats accounting into stats.go + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Extract `drift.go` (file split, no logic change) + +**Files:** +- Create: `internal/session/drift.go` +- Modify: `internal/session/session.go` + +- [ ] **Step 1: Create the new file** + +Create `internal/session/drift.go`: + +```go +package session +``` + +- [ ] **Step 2: Move the consts verbatim** + +Cut these const groups from `session.go` and paste into `drift.go`: + +- the block `const ( driftDebounce ... driftTimeout ... enforceTimeout ... nudgeDebounce ... nudgeTimeout ... )` +- `const recentTitlesMax = 10` +- the block `const ( driftIdle ... driftPending ... driftOnTask ... driftDrifting ... )` + +- [ ] **Step 3: Move the methods verbatim** + +Cut these from `session.go` and paste unedited into `drift.go`: + +- `Set{DriftJudge,Guard,Nudge}` — i.e. `SetDriftJudge`, `SetGuard`, `SetNudge` +- `resetDriftLocked`, `OnTask`, `Refocus` +- `commitmentLineLocked` +- `RecordWindow`, `enforceActionLocked`, `evaluateDriftLocked`, `maybeNudgeLocked` +- `recordTitleLocked`, `applyVerdictLocked` +- `recentTitlesForTest` + +- [ ] **Step 4: Fix imports** + +Run: `go build ./internal/session/` +`drift.go` is expected to need: `context`, `log`, `time`, `antidrift/internal/ai`, `antidrift/internal/domain`, `antidrift/internal/enforce`, `antidrift/internal/evidence`. Add/remove per the compiler (or `goimports -w`). Pay attention: `session.go` may still need `enforce`/`ai` for fields in the `Controller` struct (the struct itself stays in `session.go`), so do not blindly delete its imports — let the compiler decide. + +- [ ] **Step 5: Green gate** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/session/drift.go internal/session/session.go +git commit -m "Split drift/nudge/enforcement into drift.go + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Extract `roles.go` (file split, no logic change) + +This isolates the four async roles so Phase 2's consolidation is a clean diff. `session.go` becomes the remainder (core controller + lifecycle). + +**Files:** +- Create: `internal/session/roles.go` +- Modify: `internal/session/session.go` + +- [ ] **Step 1: Create the new file** + +Create `internal/session/roles.go`: + +```go +package session +``` + +- [ ] **Step 2: Move the consts verbatim** + +Cut these from `session.go` into `roles.go`: + +- `const coachTimeout = 60 * time.Second` and the `const ( coachIdle ... coachPending ... coachReady ... coachError )` block +- `const tasksTimeout = 30 * time.Second` and the `const ( tasksIdle ... tasksReady ... tasksError )` block +- `const knowledgeTimeout = 10 * time.Second` and the `const ( knowledgeIdle ... knowledgeError )` block +- `const reflectionTimeout = 30 * time.Second`, `const reflectionHistoryN = 5`, `const reflectionTopBuckets = 3`, and the `const ( reflectionIdle ... reflectionAbsent )` block + +- [ ] **Step 3: Move the methods/functions verbatim** + +Cut these from `session.go` into `roles.go`: + +- coach: `SetCoach`, `resetCoachLocked`, `composedGroundingLocked`, `RequestCoach`, `coachErrorMessage` +- tasks: `SetTasks`, `startTasksFetchLocked` +- knowledge: `SetKnowledge`, `SetKnowledgePath`, `startKnowledgeFetchLocked` +- reflection: `SetReviewer`, `startReflectionFetchLocked`, `buildReflectionFinishedLocked`, `buildReflectionHistory` + +- [ ] **Step 4: Fix imports** + +Run: `go build ./internal/session/` +`roles.go` is expected to need: `context`, `fmt`, `strings`, `time`, `antidrift/internal/ai`, `antidrift/internal/domain`, `antidrift/internal/knowledge`, `antidrift/internal/store`, `antidrift/internal/tasks`. Add/remove per the compiler (or `goimports -w`). + +- [ ] **Step 5: Green gate + confirm the monolith shrank** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS. +Run: `wc -l internal/session/*.go` +Expected: `session.go` is now well under ~450 lines; `views.go`/`roles.go`/`drift.go`/`stats.go` carry the rest. + +- [ ] **Step 6: Commit** + +```bash +git add internal/session/roles.go internal/session/session.go +git commit -m "Split async AI roles into roles.go + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: Add `runFetchAsync` and migrate the tasks role + +Phase 2 begins. Introduce the helper and convert the first role. The helper owns only the goroutine dance; the role keeps its setup, stale guard, and apply branches. + +**Files:** +- Modify: `internal/session/roles.go` + +- [ ] **Step 1: Add the helper** + +In `internal/session/roles.go`, add: + +```go +// runFetchAsync launches a generation-guarded background fetch. The caller has +// already captured its dependencies and (for the *Locked callers) holds c.mu; +// this method only spawns the goroutine, which re-acquires the lock itself. +// fetch performs the I/O with no lock held; stale reports whether to discard the +// result; apply records it under the re-acquired lock (and persists itself when +// the role requires it). On a non-stale completion the controller notifies. +func (c *Controller) runFetchAsync(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + fetch(ctx) + c.mu.Lock() + if stale() { + c.mu.Unlock() + return + } + apply() + c.mu.Unlock() + c.notify() + }() +} +``` + +- [ ] **Step 2: Migrate `startTasksFetchLocked`** + +Replace the body of `startTasksFetchLocked` (the `go func() { ... }()` block) so the function reads exactly: + +```go +func (c *Controller) startTasksFetchLocked() { + c.tasksList = nil + if c.tasksProvider == nil { + c.tasksStatus = tasksIdle + return + } + c.tasksGen++ + gen := c.tasksGen + c.tasksStatus = tasksPending + provider := c.tasksProvider + var list []tasks.Task + var err error + c.runFetchAsync(tasksTimeout, + func(ctx context.Context) { list, err = provider.Today(ctx) }, + func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning }, + func() { + if err != nil { + c.tasksStatus = tasksError + c.tasksList = nil + } else { + c.tasksStatus = tasksReady + c.tasksList = list + } + }) +} +``` + +- [ ] **Step 3: Targeted tests** + +Run: `go test -race ./internal/session/ -run 'Tasks' -v` +Expected: PASS — including `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`, `TestStaleTasksFetchDiscardedAfterLeavingPlanning`. + +- [ ] **Step 4: Full green gate** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/session/roles.go +git commit -m "Add runFetchAsync helper and migrate the tasks fetch + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: Migrate the knowledge role + +**Files:** +- Modify: `internal/session/roles.go` + +- [ ] **Step 1: Migrate `startKnowledgeFetchLocked`** + +Replace the `go func() { ... }()` block so the function reads exactly (note the three-branch apply and the `knowledgePath` write-back are preserved verbatim, now inside the `apply` closure): + +```go +func (c *Controller) startKnowledgeFetchLocked() { + c.knowledgeText = "" + c.knowledgeChars = 0 + if c.knowledgeSrc == nil { + c.knowledgeStatus = knowledgeIdle + return + } + c.knowledgeGen++ + gen := c.knowledgeGen + c.knowledgeStatus = knowledgePending + src := c.knowledgeSrc + path := c.knowledgePath + var prof knowledge.Profile + var err error + c.runFetchAsync(knowledgeTimeout, + func(ctx context.Context) { prof, err = src.Load(ctx, path) }, + func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning }, + func() { + if err != nil { + c.knowledgeStatus = knowledgeError + c.knowledgeText = "" + c.knowledgeChars = 0 + if prof.Path != "" { + c.knowledgePath = prof.Path + } + } else if prof.Text == "" { + c.knowledgeStatus = knowledgeAbsent + c.knowledgeText = "" + c.knowledgeChars = 0 + c.knowledgePath = prof.Path + } else { + c.knowledgeStatus = knowledgeReady + c.knowledgeText = prof.Text + c.knowledgeChars = len(prof.Text) + c.knowledgePath = prof.Path + } + }) +} +``` + +- [ ] **Step 2: Targeted tests** + +Run: `go test -race ./internal/session/ -run 'Knowledge' -v` +Expected: PASS — including `TestEnterPlanningLoadsKnowledge`, `TestKnowledgeAbsentWhenEmptyText`, `TestKnowledgeLoadError`, `TestNoSourceNoKnowledgeView`, `TestKnowledgeViewAbsentOutsidePlanning`, `TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning` (from Task 1). + +- [ ] **Step 3: Full green gate** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/session/roles.go +git commit -m "Migrate the knowledge fetch onto runFetchAsync + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: Migrate the coach role + +`RequestCoach` is the request-triggered entry point: it manages its own lock and notifies the pending state **before** launching. Keep that pre-launch unlock+notify; only the trailing `go func() { ... }()` is replaced. + +**Files:** +- Modify: `internal/session/roles.go` + +- [ ] **Step 1: Migrate `RequestCoach`** + +Replace the trailing goroutine. The function must read exactly: + +```go +func (c *Controller) RequestCoach(intent string) error { + c.mu.Lock() + if c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return ErrNotPlanning + } + if c.coach == nil { + c.coachStatus = coachError + c.coachErr = "coach unavailable" + c.coachProposal = nil + c.mu.Unlock() + c.notify() + return nil + } + c.coachGen++ + gen := c.coachGen + c.coachStatus = coachPending + c.coachErr = "" + c.coachProposal = nil + coach := c.coach + grounding := c.composedGroundingLocked() + c.mu.Unlock() + c.notify() + + var prop ai.Proposal + var err error + c.runFetchAsync(coachTimeout, + func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) }, + func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning }, + func() { + if err != nil { + c.coachStatus = coachError + c.coachErr = coachErrorMessage(err) + c.coachProposal = nil + } else { + c.coachStatus = coachReady + c.coachProposal = &prop + c.coachErr = "" + } + }) + return nil +} +``` + +Note: `runFetchAsync` is called here **after** `c.mu.Unlock()`. That is correct — the helper only spawns the goroutine and touches no `c` field before the goroutine re-locks, so holding the lock is not required. + +- [ ] **Step 2: Targeted tests** + +Run: `go test -race ./internal/session/ -run 'Coach' -v` +Expected: PASS — including `TestRequestCoachReady`, `TestRequestCoachError`, `TestRequestCoachUnavailable`, `TestRequestCoachWrongState`, `TestRequestCoachStaleResultDiscarded`, `TestCoachReceivesCachedGrounding`, `TestLeavingPlanningClearsCoach`, `TestCarryForwardGroundsNextCoach`. + +- [ ] **Step 3: Full green gate** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/session/roles.go +git commit -m "Migrate the coach fetch onto runFetchAsync + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 9: Migrate the reflection role + +Reflection is the subtle one: its stale guard is **gen-only** (no Planning gate — the carry-forward must survive `End` before the reviewer returns), it reads `history` **synchronously under the lock before** the goroutine (a happens-before requirement against `End`'s audit-chain append), and its apply calls `persistLocked`. All three must be preserved. + +**Files:** +- Modify: `internal/session/roles.go` + +- [ ] **Step 1: Migrate `startReflectionFetchLocked`** + +Replace the trailing `go func() { ... }()` block. The function must read exactly (the synchronous `history := buildReflectionHistory(c.auditPath)` stays **outside** the closures, before the `runFetchAsync` call; `persistLocked` moves **into** the `apply` closure): + +```go +func (c *Controller) startReflectionFetchLocked() { + c.reflectionRecap = "" + c.carryForward = "" + if c.reviewer == nil { + c.reflectionStatus = reflectionIdle + return + } + c.reflectionGen++ + gen := c.reflectionGen + c.reflectionStatus = reflectionPending + reviewer := c.reviewer + finished := c.buildReflectionFinishedLocked() + // Read the history synchronously, here under the lock, on purpose: it must + // happen-before End appends the just-finished session to the audit chain, so + // that session is excluded from "recent history" and not double-counted (it + // is already carried in `finished`). Moving this into the goroutine would + // race with End's append and reintroduce that double-count. The read is + // bounded to reflectionHistoryN summaries and runs once per Review entry, not + // on any hot path. + history := buildReflectionHistory(c.auditPath) + var refl ai.Reflection + var err error + c.runFetchAsync(reflectionTimeout, + func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) }, + func() bool { return gen != c.reflectionGen }, + func() { + if err != nil || strings.TrimSpace(refl.Recap) == "" { + c.reflectionStatus = reflectionAbsent + c.reflectionRecap = "" + c.carryForward = "" + } else { + c.reflectionStatus = reflectionReady + c.reflectionRecap = refl.Recap + c.carryForward = refl.CarryForward + } + _ = c.persistLocked() + }) +} +``` + +Confirm the reviewer's return type is `ai.Reflection` (grep `internal/ai` for `func (b *Backend) Review`); if the type name differs, match it in the `var refl` declaration. + +- [ ] **Step 2: Targeted tests** + +Run: `go test -race ./internal/session/ -run 'Reflection|CarryForward' -v` +Expected: PASS — including `TestReflectionFetchedOnReview`, `TestNoReviewerYieldsIdleReflection`, `TestCarryForwardGroundsNextCoach`, `TestReflectionStaleResultDiscarded`, `TestCarryForwardSurvivesRestart`. + +- [ ] **Step 3: Full green gate** + +Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/session/roles.go +git commit -m "Migrate the reflection fetch onto runFetchAsync + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 10: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Confirm no inline async-fetch goroutines remain** + +Run: `grep -n 'go func()' internal/session/*.go` +Expected: the only matches are inside `runFetchAsync` (in `roles.go`) and any pre-existing goroutines in `drift.go`/elsewhere that are **not** the four migrated role fetches. The coach/tasks/knowledge/reflection fetches must no longer contain their own `go func()` — they go through `runFetchAsync`. + +- [ ] **Step 2: Confirm the split** + +Run: `wc -l internal/session/*.go` +Expected: five focused files (`session.go`, `views.go`, `roles.go`, `drift.go`, `stats.go`), none dominating; the old 1278-line monolith is gone. + +- [ ] **Step 3: Full suite under the race detector** + +Run: `go build ./... && go vet ./... && go test -race ./...` +Expected: all packages PASS, no race warnings. + +- [ ] **Step 4: Confirm behavior contract** + +Confirm via `git log --oneline` that the milestone is a series of small commits, each green, with no change to any exported signature (spot-check: `git diff ^..HEAD -- internal/session/session.go internal/web internal/store cmd` shows no signature/behavior changes outside the moved/▸consolidated session code, and no `.go` file outside `internal/session/` was modified except as already committed in prior milestones). + +--- + +## Self-review notes (for the executor) + +- Every task is **green-to-green**; if any `go test -race` goes red, stop and fix before committing — a red gate means a real behavior change slipped in. +- The risky preservation points, all called out in their tasks: reflection's **gen-only** stale guard, its **synchronous history read** before the goroutine, and its **`persistLocked`** in apply; coach's **pre-launch unlock+notify**; knowledge's **three-branch apply + `knowledgePath` write-back**. +- No exported symbol is renamed or moved out of `package session`; `web_test.go`'s cross-package use of `AllowedClassesForTest`/`EnforcementLevelForTest` keeps working because those stay in `session.go` as regular (non-`_test.go`) methods.