Add M9 tame-session refactor implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:31:57 -04:00
parent 79b3c0be95
commit 465938906e
@@ -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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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 <first-m9-commit>^..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.