Add M9 tame-session refactor implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user