Compare commits

...

11 Commits

Author SHA1 Message Date
felixm 895ad342c7 Migrate the reflection fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:12:10 -04:00
felixm 59283be733 Migrate the coach fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:08:48 -04:00
felixm fbbff6966d Migrate the knowledge fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:06:09 -04:00
felixm db0d11ea2e Add runFetchAsync helper and migrate the tasks fetch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:05:08 -04:00
felixm d3f0526355 Split async AI roles into roles.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:57:26 -04:00
felixm 0d3294b30d Split drift/nudge/enforcement into drift.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:50:53 -04:00
felixm d068e2f79c Split evidence-stats accounting into stats.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:15 -04:00
felixm a0f144968b Split session views into views.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:42:53 -04:00
felixm f13f33cf74 Add knowledge stale-discard characterization test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:39:40 -04:00
felixm 465938906e Add M9 tame-session refactor implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:31:57 -04:00
felixm 79b3c0be95 Add M9 tame-session refactor design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:24:36 -04:00
8 changed files with 1944 additions and 937 deletions
@@ -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.
@@ -0,0 +1,201 @@
# M9 — Tame `session.go`: Design
**Status:** approved
**Date:** 2026-06-01
**Milestone:** M9 — Maintainability: split the monolithic `session.go` and
consolidate the duplicated async-fetch boilerplate, with zero behavior change
## Purpose
The M0M8 feature arc left `session.Controller` carrying five responsibilities
in a single 1278-line file — by far the largest in the codebase (the next is
`web.go` at 243). Every milestone's design doc has flagged two specific debts:
the file is too big to hold in context at once, and the per-role async-fetch
block (capture generation → goroutine → re-lock → latest-wins) is copy-pasted
across coach, tasks, knowledge, and reflection.
M9 pays both down so the controller is easy to extend before any new feature
lands. It is a **pure maintainability milestone**: no new behavior, no API
change, no exported-symbol rename. Success is the existing test suite passing
**green-to-green under `-race`**, before and after.
## Scope
Two changes, both confined to `package session`:
1. **File split** — move declarations (no logic edits) out of the monolith into
focused files, each with one clear responsibility.
2. **Async-fetch consolidation** — extract the mechanical goroutine dance shared
by the four async fetches into one helper, while every real per-role
difference stays explicit at the call site.
Everything else — drift/stats/web/daemon logic, the deferred M8 Tiers B/C —
is untouched.
## The async-fetch helper
Today four methods (`RequestCoach`, `startTasksFetchLocked`,
`startKnowledgeFetchLocked`, `startReflectionFetchLocked`) repeat the same
goroutine skeleton: open a timeout context, perform the I/O with no lock held,
re-acquire `c.mu`, discard the result if a generation guard says it is stale,
otherwise record it and `notify`. The role-specific parts around that skeleton
genuinely differ and **must stay per-role**:
- the generation field (`coachGen` / `tasksGen` / `knowledgeGen` /
`reflectionGen`) and status enum;
- the stale guard — coach/tasks/knowledge check *gen mismatch **or** left
Planning*; reflection checks *gen only* (its carry-forward must survive `End`
before the reviewer returns);
- the apply logic — tasks/coach are two-branch; knowledge is three-branch and
writes `knowledgePath` back; reflection is two-branch and calls
`persistLocked`;
- pre-goroutine work — reflection reads `history` synchronously under the lock,
a happens-before requirement against `End`'s audit-chain append, which must be
preserved;
- `RequestCoach` manages its own lock and `notify`s the pending state before
launching; the `*Locked` variants are launched mid-transition from
`EnterPlanning` / `enterReview` while the caller still holds `c.mu`.
The helper therefore extracts **only** the mechanical dance and takes three
closures plus the timeout:
```go
// runFetchAsync launches a generation-guarded background fetch. The caller has
// captured its dependencies and (for the *Locked callers) holds c.mu; this
// method only spawns the goroutine. 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).
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()
}()
}
```
Each role keeps its own setup (clear cache, nil-provider short-circuit, `gen++`,
pending status, dep capture) and passes `fetch` / `stale` / `apply` closures
over its locals. Example (tasks):
```go
func (c *Controller) startTasksFetchLocked() {
c.tasksList = nil
if c.tasksProvider == nil {
c.tasksStatus = tasksIdle
return
}
c.tasksGen++
gen := c.tasksGen
c.tasksStatus = tasksPending
p := c.tasksProvider
var list []tasks.Task
var err error
c.runFetchAsync(tasksTimeout,
func(ctx context.Context) { list, err = p.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
}
})
}
```
`runFetchAsync` does not require the lock to be held (it only spawns the
goroutine, which re-acquires `c.mu` itself), so it is safe to call both from a
`*Locked` caller still inside a transition and from `RequestCoach` after it has
unlocked and notified.
**Rejected alternatives:**
- *Generic free function* `asyncFetch[T](c, timeout, fetch (ctx)(T,error),
stale, apply func(T,error))`. More type-safe — the result flows as a typed
value rather than a captured closure var — but Go methods cannot be generic,
so it must be a package-level function, and the per-role branches still live
in `apply`. The closure-method form is the smaller, lock-idiomatic diff.
- *Struct-per-role value* encapsulating `gen` + status + timeout. The most
structure but the most churn; four small roles do not justify the machinery
(YAGNI).
- *Unifying the four `gen` int fields* into one shared counter type. Pure churn
for no payoff; out of scope.
## File decomposition
All files remain `package session`. **No exported symbol moves out of the
package, is renamed, or changes signature** — only the file a declaration lives
in changes.
| File | Responsibility | Declarations |
| ---- | -------------- | ------------ |
| `session.go` | core controller + lifecycle | `Controller` struct, `New`, `SetClock`/`SetOnChange`/`notify`, `State`/`Deadline`, `persistLocked`, the lifecycle transitions (`EnterPlanning`, `StartManualCommitment`, `Complete`/`Expire`/`enterReview`, `End`, `buildSummaryLocked`), `ErrNotPlanning`/`ErrNotActive` |
| `views.go` | UI projection (pure data shaping) | the 11 `*View` types, the `State` type, `stateLocked`, `bucketViews` |
| `roles.go` | AI roles + the async-fetch helper | `runFetchAsync`; coach (`SetCoach`, `resetCoachLocked`, `composedGroundingLocked`, `RequestCoach`, `coachErrorMessage`); tasks (`SetTasks`, `startTasksFetchLocked`); knowledge (`SetKnowledge`, `SetKnowledgePath`, `startKnowledgeFetchLocked`); reflection (`SetReviewer`, `startReflectionFetchLocked`, `buildReflectionFinishedLocked`, `buildReflectionHistory`); the coach/tasks/knowledge/reflection timeout + status consts |
| `drift.go` | Active-state drift/nudge/enforcement | `RecordWindow`, `evaluateDriftLocked`, `maybeNudgeLocked`, `enforceActionLocked`, `applyVerdictLocked`, `resetDriftLocked`, `OnTask`, `Refocus`, `recordTitleLocked`, `commitmentLineLocked`, `Set{DriftJudge,Guard,Nudge}`, the drift/nudge/enforce consts |
| `stats.go` | per-session evidence accounting | `EvidenceStats`, `bucketKey`, `applyEvent`, `replayStats`, `keyFor`, `focusEvent`, `snapFromEvent` |
The `*ForTest` accessors (`AllowedClassesForTest`, `EnforcementLevelForTest`,
`recentTitlesForTest`) stay in regular `.go` files (not `_test.go`) grouped with
the cluster they expose, because `internal/web/web_test.go` reaches some of them
across the package boundary; a `_test.go` placement would be invisible to that
package and break the build. The plan confirms each accessor's call sites before
choosing its file.
Splitting into *sub-packages* is explicitly rejected: every method mutates one
`Controller` behind one `sync.Mutex`, so sub-packages would force that private
state to be exported. One package across several files is the idiomatic Go shape
and keeps the locking invariant intact.
## Sequencing & safety
The discipline for a refactor of the controller is behavior preservation proven
by the current tests:
- **Phase 1 — file split (zero logic change).** Move declarations into the new
files. `go build ./...` + `go vet ./...` + `go test -race ./...` green. Lowest
risk, done first so the structure exists before any logic moves. One small
commit per file extracted.
- **Phase 2 — consolidation.** Add `runFetchAsync`; migrate the four roles to it
**one at a time**, each its own commit, the full `-race` suite green between
each migration. Performing this after the split means each migration is a
clean diff inside `roles.go` rather than inside the old monolith.
At no point is the build or the suite left red. The split being first means a
mistake there is caught before any semantically-meaningful change is layered on.
## Testing
No new behavior means no new behavioral tests are *required*; the contract is
green-to-green under `-race`. The plan first **audits** that the existing suite
covers each async role's:
- stale-generation discard,
- the not-Planning completion gate (coach/tasks/knowledge),
- reflection's gen-only guard plus its `persistLocked` on completion,
- knowledge's three-branch apply and `knowledgePath` write-back.
A characterization test is added **only where the audit finds a real gap** — so
the consolidation cannot silently change a path the suite never exercised.
Otherwise the existing `session_test.go` and `web_test.go` are the safety net,
run after every commit.
## Out of scope
- Any behavior change, API/signature change, or exported-symbol rename.
- Unifying the four `gen` int fields into a shared type.
- Touching drift/stats/web/daemon **logic** (only moving declarations).
- Splitting `session` into sub-packages.
- M8 Tiers B (network blocking) and C (privileged entry gate) — separate
milestones.
+326
View File
@@ -0,0 +1,326 @@
package session
import (
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/store"
"context"
"log"
"strings"
"time"
)
const (
driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second
enforceTimeout = 5 * time.Second
nudgeDebounce = 5 * time.Minute
nudgeTimeout = 30 * time.Second
)
const recentTitlesMax = 10
const (
driftIdle = "idle"
driftPending = "pending"
driftOnTask = "ontask"
driftDrifting = "drifting"
)
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
// working; unmatched windows simply stay idle.
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
c.mu.Lock()
c.judge = j
c.mu.Unlock()
}
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
// enforcement; everything else behaves identically.
func (c *Controller) SetGuard(g enforce.Guard) {
c.mu.Lock()
defer c.mu.Unlock()
c.guard = g
}
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
// local matching and the drift judge are unaffected.
func (c *Controller) SetNudge(n ai.Nudger) {
c.mu.Lock()
c.nudge = n
c.mu.Unlock()
}
// commitmentLineLocked renders the active commitment as a single line for AI
// prompts, or "" if there is none. Caller holds mu.
func (c *Controller) commitmentLineLocked() string {
if c.commitment == nil {
return ""
}
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
}
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
func (c *Controller) recentTitlesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.recentTitles...)
}
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
func (c *Controller) resetDriftLocked() {
c.driftStatus = driftIdle
c.driftReason = ""
c.driftGen++
c.nudgeEpoch++
c.lastJudgedAt = time.Time{}
c.judgedClasses = map[string]ai.Verdict{}
c.recentTitles = nil
c.nudgeMessage = ""
c.lastNudgedAt = time.Time{}
}
// OnTask appends the current window class to the session allowed-context, clears
// drift, drops any cached verdict for that class, and persists. The class now
// matches locally and is never re-judged.
func (c *Controller) OnTask() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
var class string
if c.stats != nil {
class = c.stats.Current.Class
}
if strings.TrimSpace(class) != "" {
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if !evidence.MatchesAllowed(ac, class, "") {
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
}
delete(c.judgedClasses, class)
}
c.driftStatus = driftOnTask
c.driftReason = ""
return c.persistLocked()
}
// Refocus clears the current drift verdict without changing allowed-context, and
// drops the cached verdict for the current class so it may be judged again later.
func (c *Controller) Refocus() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
if c.stats != nil {
delete(c.judgedClasses, c.stats.Current.Class)
}
c.driftStatus = driftIdle
c.driftReason = ""
return c.persistLocked()
}
// RecordWindow ingests one sensor observation. It accumulates time only while
// Active, always tracks the latest window for display, and fires onChange after
// releasing the mutex.
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
c.mu.Lock()
c.latestWindow = snap
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
c.mu.Unlock()
c.notify()
return
}
now := c.clock()
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
c.applyEvent(now, snap)
c.recordTitleLocked(snap.Title)
launch := c.evaluateDriftLocked(now, snap)
enforceAct := c.enforceActionLocked()
c.mu.Unlock()
if launch != nil {
go launch()
}
if enforceAct != nil {
go enforceAct()
}
c.notify()
}
// enforceActionLocked returns the minimize thunk when this observation should be
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
// returned func performs blocking X11 I/O and MUST run after the caller releases
// c.mu, following the off-lock async-I/O discipline used by the other roles.
func (c *Controller) enforceActionLocked() func() {
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
return nil
}
guard := c.guard
return func() {
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
defer cancel()
if err := guard.MinimizeActive(ctx); err != nil {
log.Printf("session: enforce minimize failed: %v", err)
}
}
}
// evaluateDriftLocked runs the local-first drift pipeline for one observation
// and updates synchronous drift state. When an async judgment is warranted it
// returns the judging closure; the caller runs it in a goroutine after
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
// already verified the runtime is Active.
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
class, title := snap.Class, snap.Title
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
// where the semantic nudge runs — the drift judge stays silent here, so the
// two are mutually exclusive per observation.
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if evidence.MatchesAllowed(ac, class, title) {
c.driftStatus = driftOnTask
c.driftReason = ""
return c.maybeNudgeLocked(now)
}
// Past this point the window is not a local on-task match: the on-task
// stretch (if any) has ended. Void any soft nudge tied to it and advance the
// epoch so an in-flight nudge result is discarded rather than surfacing stale
// when the user returns.
c.nudgeMessage = ""
c.nudgeEpoch++
// 2. Per-class cache.
if v, ok := c.judgedClasses[class]; ok {
c.applyVerdictLocked(v)
return nil
}
// 3. No judge wired: never block; leave idle.
if c.judge == nil {
c.driftStatus = driftIdle
c.driftReason = ""
return nil
}
// 4. Debounce: at most one judgment per driftDebounce window.
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
return nil
}
prevStatus, prevReason := c.driftStatus, c.driftReason
c.driftGen++
gen := c.driftGen
c.lastJudgedAt = now
c.driftStatus = driftPending
c.driftReason = ""
judge := c.judge
commitment := c.commitmentLineLocked()
return func() {
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
defer cancel()
v, err := judge.JudgeDrift(ctx, commitment, class, title)
c.mu.Lock()
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: a newer judgment started or the session ended
}
if err != nil {
// Degrade: never fabricate drift. Revert the optimistic pending.
if c.driftStatus == driftPending {
c.driftStatus = prevStatus
c.driftReason = prevReason
}
c.mu.Unlock()
log.Printf("session: drift judge failed: %v", err)
c.notify()
return
}
c.judgedClasses[class] = v
var enforceAct func()
if c.stats != nil && c.stats.Current.Class == class {
c.applyVerdictLocked(v)
enforceAct = c.enforceActionLocked()
}
c.mu.Unlock()
if enforceAct != nil {
enforceAct()
}
c.notify()
}
}
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
// local-match path. It returns the nudging closure when eligible (judge wired,
// at least two titles of history, debounce elapsed), else nil. The closure
// follows the drift-judge discipline: it runs in a goroutine after the caller
// releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
// episode or a session change — is discarded rather than surfacing stale, never
// fabricates a concern on error, and notifies only after unlocking. Caller holds
// mu and has already verified the runtime is Active.
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
if c.nudge == nil || len(c.recentTitles) < 2 {
return nil
}
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
return nil
}
c.lastNudgedAt = now
epoch := c.nudgeEpoch
nudge := c.nudge
commitment := c.commitmentLineLocked()
titles := append([]string(nil), c.recentTitles...)
return func() {
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
defer cancel()
msg, err := nudge.Nudge(ctx, commitment, titles)
c.mu.Lock()
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: the on-task stretch ended (drift episode) or the session changed
}
if err != nil {
c.mu.Unlock()
log.Printf("session: nudge failed: %v", err)
return // never fabricate a concern; leave any prior message intact
}
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
c.mu.Unlock()
c.notify()
}
}
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
func (c *Controller) recordTitleLocked(title string) {
t := strings.TrimSpace(title)
if t == "" {
return
}
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
return
}
c.recentTitles = append(c.recentTitles, t)
if len(c.recentTitles) > recentTitlesMax {
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
}
}
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
if v.OnTask {
c.driftStatus = driftOnTask
c.driftReason = ""
return
}
c.driftStatus = driftDrifting
c.driftReason = v.Reason
}
+382
View File
@@ -0,0 +1,382 @@
package session
import (
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/knowledge"
"antidrift/internal/store"
"antidrift/internal/tasks"
"context"
"errors"
"fmt"
"strings"
"time"
)
const coachTimeout = 60 * time.Second
const (
coachIdle = "idle"
coachPending = "pending"
coachReady = "ready"
coachError = "error"
)
const tasksTimeout = 30 * time.Second
const (
tasksIdle = "idle"
tasksPending = "pending"
tasksReady = "ready"
tasksError = "error"
)
const knowledgeTimeout = 10 * time.Second
const (
knowledgeIdle = "idle"
knowledgePending = "pending"
knowledgeReady = "ready"
knowledgeAbsent = "absent"
knowledgeError = "error"
)
const reflectionTimeout = 30 * time.Second
const reflectionHistoryN = 5
// reflectionTopBuckets caps how many per-window time buckets the finished-session
// block lists for the reviewer, keeping the prompt compact.
const reflectionTopBuckets = 3
const (
reflectionIdle = "idle"
reflectionPending = "pending"
reflectionReady = "ready"
reflectionAbsent = "absent"
)
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully.
func (c *Controller) SetCoach(coach ai.Coach) {
c.mu.Lock()
c.coach = coach
c.mu.Unlock()
}
// resetCoachLocked returns coach state to idle and invalidates any in-flight
// request. Caller holds mu.
func (c *Controller) resetCoachLocked() {
c.coachStatus = coachIdle
c.coachProposal = nil
c.coachErr = ""
c.coachGen++
}
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
// the planning tasks list absent.
func (c *Controller) SetTasks(p tasks.Provider) {
c.mu.Lock()
c.tasksProvider = p
c.mu.Unlock()
}
// 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.
//
// Closure contract:
// - fetch performs the I/O and runs with NO lock held; it must not touch c
// fields without taking c.mu itself.
// - stale runs under the re-acquired c.mu and returns true to DISCARD the
// result (e.g. a newer generation superseded this one).
// - apply runs under the re-acquired c.mu and records the result (persisting
// itself when the role requires it); it must NOT call c.notify — the helper
// owns the post-unlock notify, and notify self-locks, so calling it under
// c.mu would deadlock.
//
// On a non-stale completion the helper notifies after releasing the lock.
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()
}()
}
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
// post-planning results, and notifies on completion. Caller holds mu.
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
}
})
}
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
// keeps the planning knowledge view absent and the coach ungrounded.
func (c *Controller) SetKnowledge(s knowledge.Source) {
c.mu.Lock()
c.knowledgeSrc = s
c.mu.Unlock()
}
// SetKnowledgePath selects an explicit profile path (session-only; not
// persisted). While planning, it re-loads immediately so the indicator and the
// cached grounding update. An empty path resets to the adapter default.
func (c *Controller) SetKnowledgePath(path string) {
c.mu.Lock()
c.knowledgePath = strings.TrimSpace(path)
planning := c.runtimeState == domain.RuntimePlanning
if planning {
c.startKnowledgeFetchLocked()
}
c.mu.Unlock()
if planning {
c.notify()
}
}
// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set.
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
// post-planning results, and notifies on completion. The loaded text is cached
// in knowledgeText for the coach to read. Caller holds mu.
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
}
})
}
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
// leaves the coach ungrounded by any carry-forward.
func (c *Controller) SetReviewer(r ai.Reviewer) {
c.mu.Lock()
c.reviewer = r
c.mu.Unlock()
}
// startReflectionFetchLocked kicks off an asynchronous reflection when a
// reviewer is set, on entering Review. Unlike the tasks/knowledge fetches, the
// completion guard is generation-only (not state-gated): the carry-forward must
// still apply if the user clicks End before the reviewer returns. A superseded
// review (a later session's fetch) bumps the generation and discards this one.
// The recap and carry-forward are cleared up front so a failed/slow reviewer
// never leaves stale data from the previous session. Caller holds mu.
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()
})
}
// buildReflectionFinishedLocked renders the just-finished session as a compact
// block for the reviewer. Caller holds mu; c.stats/c.commitment are still set
// (End clears them, but enterReview runs before End). Reuses bucketViews for the
// per-window totals, already sorted desc by seconds.
func (c *Controller) buildReflectionFinishedLocked() string {
var na, sc string
if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
}
outcome := c.outcomePending
if outcome == "" {
outcome = "completed"
}
var b strings.Builder
fmt.Fprintf(&b, "Next action: %s\n", na)
fmt.Fprintf(&b, "Success condition: %s\n", sc)
fmt.Fprintf(&b, "Outcome: %s\n", outcome)
if c.stats != nil {
fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount)
for i, bv := range bucketViews(c.stats.Buckets) {
if i >= reflectionTopBuckets {
break
}
fmt.Fprintf(&b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
}
}
return strings.TrimRight(b.String(), "\n")
}
// buildReflectionHistory renders the last few prior sessions as compact lines.
// The just-finished session is not yet in the chain (End appends it), so it is
// not double-counted. Returns "" when there is no usable history.
func buildReflectionHistory(auditPath string) string {
sums, err := store.RecentSessions(auditPath, reflectionHistoryN)
if err != nil || len(sums) == 0 {
return ""
}
var b strings.Builder
for _, s := range sums {
top := ""
if len(s.Buckets) > 0 {
top = fmt.Sprintf(", top %s %dm", s.Buckets[0].Class, s.Buckets[0].Seconds/60)
}
fmt.Fprintf(&b, "- %s: %s (%d switches%s)\n", s.Outcome, s.NextAction, s.SwitchCount, top)
}
return strings.TrimRight(b.String(), "\n")
}
// composedGroundingLocked combines the standing profile (knowledge port) with
// the latest carry-forward takeaway into the single free-form grounding string
// the coach already accepts. Caller holds mu.
func (c *Controller) composedGroundingLocked() string {
g := c.knowledgeText
if c.carryForward != "" {
if g != "" {
g += "\n\n"
}
g += "Last session's takeaway: " + c.carryForward
}
return g
}
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted.
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
}
func coachErrorMessage(err error) string {
switch {
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
return "coach returned an unusable response"
case errors.Is(err, context.DeadlineExceeded):
return "coach timed out"
default:
return "coach unavailable"
}
}
-937
View File
@@ -5,13 +5,10 @@
package session
import (
"context"
"errors"
"fmt"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -32,85 +29,10 @@ const (
sessionRetention = 30 * 24 * time.Hour
)
const coachTimeout = 60 * time.Second
const (
coachIdle = "idle"
coachPending = "pending"
coachReady = "ready"
coachError = "error"
)
const tasksTimeout = 30 * time.Second
const (
tasksIdle = "idle"
tasksPending = "pending"
tasksReady = "ready"
tasksError = "error"
)
const knowledgeTimeout = 10 * time.Second
const (
knowledgeIdle = "idle"
knowledgePending = "pending"
knowledgeReady = "ready"
knowledgeAbsent = "absent"
knowledgeError = "error"
)
const reflectionTimeout = 30 * time.Second
const reflectionHistoryN = 5
// reflectionTopBuckets caps how many per-window time buckets the finished-session
// block lists for the reviewer, keeping the prompt compact.
const reflectionTopBuckets = 3
const (
reflectionIdle = "idle"
reflectionPending = "pending"
reflectionReady = "ready"
reflectionAbsent = "absent"
)
const (
driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second
enforceTimeout = 5 * time.Second
nudgeDebounce = 5 * time.Minute
nudgeTimeout = 30 * time.Second
)
const recentTitlesMax = 10
const (
driftIdle = "idle"
driftPending = "pending"
driftOnTask = "ontask"
driftDrifting = "drifting"
)
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
var ErrNotActive = errors.New("session: only available while a commitment is active")
// bucketKey identifies a time bucket; Title is the scrubbed title.
type bucketKey struct{ Class, Title string }
// EvidenceStats is the in-memory accounting for the current session only.
type EvidenceStats struct {
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
}
// Controller holds runtime state and the active commitment behind a mutex.
type Controller struct {
mu sync.Mutex
@@ -166,102 +88,6 @@ type Controller struct {
lastNudgedAt time.Time
}
// CommitmentView is the UI projection of the active commitment.
type CommitmentView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
}
// ProposalView / CoachView project the ephemeral planning-coach state.
type ProposalView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
}
// DriftView is the active-only drift projection. Nudge is a separate axis from
// Status: it is populated precisely when Status is "ontask" (semantic drift
// inside an allowed app), so it cannot be folded into the status enum.
type DriftView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Nudge string `json:"nudge,omitempty"`
Enforced bool `json:"enforced,omitempty"`
}
type CoachView struct {
Status string `json:"status"`
Proposal *ProposalView `json:"proposal,omitempty"`
Error string `json:"error,omitempty"`
}
// TaskView is one to-do item in the planning tasks list.
type TaskView struct {
ID string `json:"id"`
Title string `json:"title"`
Day string `json:"day,omitempty"`
}
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
type TasksView struct {
Status string `json:"status"`
Tasks []TaskView `json:"tasks,omitempty"`
}
// KnowledgeView projects the ephemeral planning knowledge state (the standing
// profile that grounds the coach). The profile text is intentionally omitted —
// only its presence, source path, and size cross the wire.
type KnowledgeView struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
Chars int `json:"chars,omitempty"`
}
// ReflectionView projects the reviewer's output. Recap is shown on Review;
// CarryForward is shown on the next Planning screen. Unlike the knowledge
// profile, these short lines exist to be displayed, so they cross the wire.
type ReflectionView struct {
Status string `json:"status,omitempty"`
Recap string `json:"recap,omitempty"`
CarryForward string `json:"carry_forward,omitempty"`
}
// WindowView / BucketView / EvidenceView are the evidence projection.
type WindowView struct {
Class string `json:"class"`
Title string `json:"title"`
}
type BucketView struct {
Class string `json:"class"`
Title string `json:"title"`
Seconds int64 `json:"seconds"`
}
type EvidenceView struct {
Available bool `json:"available"`
Reason string `json:"reason"`
Current WindowView `json:"current"`
SwitchCount int `json:"switch_count"`
Buckets []BucketView `json:"buckets"`
}
// State is the broadcastable view of the controller.
type State struct {
RuntimeState domain.RuntimeState `json:"runtime_state"`
Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Reflection *ReflectionView `json:"reflection,omitempty"`
Drift *DriftView `json:"drift,omitempty"`
}
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
// in-memory stats from the raw log if a live session was interrupted.
func New(snapshotPath string) (*Controller, error) {
@@ -340,92 +166,6 @@ func (c *Controller) Deadline() time.Time {
return c.deadline
}
func (c *Controller) stateLocked() State {
st := State{RuntimeState: c.runtimeState}
if c.commitment != nil {
view := &CommitmentView{
NextAction: c.commitment.NextAction,
SuccessCondition: c.commitment.SuccessCondition,
TimeboxSecs: c.commitment.TimeboxSecs,
}
if !c.deadline.IsZero() {
view.DeadlineUnixSecs = c.deadline.Unix()
}
st.Commitment = view
}
if c.stats != nil {
st.Evidence = &EvidenceView{
Available: c.stats.Current.Health.Available,
Reason: c.stats.Current.Health.Reason,
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
SwitchCount: c.stats.SwitchCount,
Buckets: bucketViews(c.stats.Buckets),
}
}
if c.runtimeState == domain.RuntimePlanning {
status := c.coachStatus
if status == "" {
status = coachIdle
}
cv := &CoachView{Status: status, Error: c.coachErr}
if c.coachProposal != nil {
cv.Proposal = &ProposalView{
NextAction: c.coachProposal.NextAction,
SuccessCondition: c.coachProposal.SuccessCondition,
TimeboxSecs: c.coachProposal.TimeboxSecs,
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
}
}
st.Coach = cv
if c.tasksProvider != nil {
tstatus := c.tasksStatus
if tstatus == "" {
tstatus = tasksIdle
}
tv := &TasksView{Status: tstatus}
for _, t := range c.tasksList {
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
}
st.Tasks = tv
}
if c.knowledgeSrc != nil {
kstatus := c.knowledgeStatus
if kstatus == "" {
kstatus = knowledgeIdle
}
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
}
if c.carryForward != "" {
st.Reflection = &ReflectionView{CarryForward: c.carryForward}
}
}
if c.runtimeState == domain.RuntimeReview {
rstatus := c.reflectionStatus
if rstatus == "" {
rstatus = reflectionIdle
}
st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
}
if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus
if status == "" {
status = driftIdle
}
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
}
return st
}
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
out := make([]BucketView, 0, len(buckets))
for k, d := range buckets {
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
}
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
return out
}
func (c *Controller) persistLocked() error {
snap := store.Snapshot{
RuntimeState: c.runtimeState,
@@ -461,247 +201,6 @@ func (c *Controller) EnterPlanning() error {
return c.persistLocked()
}
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully.
func (c *Controller) SetCoach(coach ai.Coach) {
c.mu.Lock()
c.coach = coach
c.mu.Unlock()
}
// resetCoachLocked returns coach state to idle and invalidates any in-flight
// request. Caller holds mu.
func (c *Controller) resetCoachLocked() {
c.coachStatus = coachIdle
c.coachProposal = nil
c.coachErr = ""
c.coachGen++
}
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
// the planning tasks list absent.
func (c *Controller) SetTasks(p tasks.Provider) {
c.mu.Lock()
c.tasksProvider = p
c.mu.Unlock()
}
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
// post-planning results, and notifies on completion. Caller holds mu.
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
go func() {
ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout)
defer cancel()
list, err := provider.Today(ctx)
c.mu.Lock()
if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return // stale or left planning: discard
}
if err != nil {
c.tasksStatus = tasksError
c.tasksList = nil
} else {
c.tasksStatus = tasksReady
c.tasksList = list
}
c.mu.Unlock()
c.notify()
}()
}
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
// keeps the planning knowledge view absent and the coach ungrounded.
func (c *Controller) SetKnowledge(s knowledge.Source) {
c.mu.Lock()
c.knowledgeSrc = s
c.mu.Unlock()
}
// SetKnowledgePath selects an explicit profile path (session-only; not
// persisted). While planning, it re-loads immediately so the indicator and the
// cached grounding update. An empty path resets to the adapter default.
func (c *Controller) SetKnowledgePath(path string) {
c.mu.Lock()
c.knowledgePath = strings.TrimSpace(path)
planning := c.runtimeState == domain.RuntimePlanning
if planning {
c.startKnowledgeFetchLocked()
}
c.mu.Unlock()
if planning {
c.notify()
}
}
// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set.
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
// post-planning results, and notifies on completion. The loaded text is cached
// in knowledgeText for the coach to read. Caller holds mu.
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
go func() {
ctx, cancel := context.WithTimeout(context.Background(), knowledgeTimeout)
defer cancel()
prof, err := src.Load(ctx, path)
c.mu.Lock()
if gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return // stale or left planning: discard
}
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
}
c.mu.Unlock()
c.notify()
}()
}
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
// leaves the coach ungrounded by any carry-forward.
func (c *Controller) SetReviewer(r ai.Reviewer) {
c.mu.Lock()
c.reviewer = r
c.mu.Unlock()
}
// startReflectionFetchLocked kicks off an asynchronous reflection when a
// reviewer is set, on entering Review. Unlike the tasks/knowledge fetches, the
// completion guard is generation-only (not state-gated): the carry-forward must
// still apply if the user clicks End before the reviewer returns. A superseded
// review (a later session's fetch) bumps the generation and discards this one.
// The recap and carry-forward are cleared up front so a failed/slow reviewer
// never leaves stale data from the previous session. Caller holds mu.
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)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), reflectionTimeout)
defer cancel()
refl, err := reviewer.Review(ctx, finished, history)
c.mu.Lock()
if gen != c.reflectionGen {
c.mu.Unlock()
return // superseded review: discard
}
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()
c.mu.Unlock()
c.notify()
}()
}
// buildReflectionFinishedLocked renders the just-finished session as a compact
// block for the reviewer. Caller holds mu; c.stats/c.commitment are still set
// (End clears them, but enterReview runs before End). Reuses bucketViews for the
// per-window totals, already sorted desc by seconds.
func (c *Controller) buildReflectionFinishedLocked() string {
var na, sc string
if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
}
outcome := c.outcomePending
if outcome == "" {
outcome = "completed"
}
var b strings.Builder
fmt.Fprintf(&b, "Next action: %s\n", na)
fmt.Fprintf(&b, "Success condition: %s\n", sc)
fmt.Fprintf(&b, "Outcome: %s\n", outcome)
if c.stats != nil {
fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount)
for i, bv := range bucketViews(c.stats.Buckets) {
if i >= reflectionTopBuckets {
break
}
fmt.Fprintf(&b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
}
}
return strings.TrimRight(b.String(), "\n")
}
// buildReflectionHistory renders the last few prior sessions as compact lines.
// The just-finished session is not yet in the chain (End appends it), so it is
// not double-counted. Returns "" when there is no usable history.
func buildReflectionHistory(auditPath string) string {
sums, err := store.RecentSessions(auditPath, reflectionHistoryN)
if err != nil || len(sums) == 0 {
return ""
}
var b strings.Builder
for _, s := range sums {
top := ""
if len(s.Buckets) > 0 {
top = fmt.Sprintf(", top %s %dm", s.Buckets[0].Class, s.Buckets[0].Seconds/60)
}
fmt.Fprintf(&b, "- %s: %s (%d switches%s)\n", s.Outcome, s.NextAction, s.SwitchCount, top)
}
return strings.TrimRight(b.String(), "\n")
}
// AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Controller) AllowedClassesForTest() []string {
c.mu.Lock()
@@ -717,177 +216,6 @@ func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
return c.enforcementLevel
}
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
// working; unmatched windows simply stay idle.
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
c.mu.Lock()
c.judge = j
c.mu.Unlock()
}
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
// enforcement; everything else behaves identically.
func (c *Controller) SetGuard(g enforce.Guard) {
c.mu.Lock()
defer c.mu.Unlock()
c.guard = g
}
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
// local matching and the drift judge are unaffected.
func (c *Controller) SetNudge(n ai.Nudger) {
c.mu.Lock()
c.nudge = n
c.mu.Unlock()
}
// commitmentLineLocked renders the active commitment as a single line for AI
// prompts, or "" if there is none. Caller holds mu.
func (c *Controller) commitmentLineLocked() string {
if c.commitment == nil {
return ""
}
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
}
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
func (c *Controller) recentTitlesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.recentTitles...)
}
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
func (c *Controller) resetDriftLocked() {
c.driftStatus = driftIdle
c.driftReason = ""
c.driftGen++
c.nudgeEpoch++
c.lastJudgedAt = time.Time{}
c.judgedClasses = map[string]ai.Verdict{}
c.recentTitles = nil
c.nudgeMessage = ""
c.lastNudgedAt = time.Time{}
}
// OnTask appends the current window class to the session allowed-context, clears
// drift, drops any cached verdict for that class, and persists. The class now
// matches locally and is never re-judged.
func (c *Controller) OnTask() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
var class string
if c.stats != nil {
class = c.stats.Current.Class
}
if strings.TrimSpace(class) != "" {
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if !evidence.MatchesAllowed(ac, class, "") {
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
}
delete(c.judgedClasses, class)
}
c.driftStatus = driftOnTask
c.driftReason = ""
return c.persistLocked()
}
// Refocus clears the current drift verdict without changing allowed-context, and
// drops the cached verdict for the current class so it may be judged again later.
func (c *Controller) Refocus() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
if c.stats != nil {
delete(c.judgedClasses, c.stats.Current.Class)
}
c.driftStatus = driftIdle
c.driftReason = ""
return c.persistLocked()
}
// composedGroundingLocked combines the standing profile (knowledge port) with
// the latest carry-forward takeaway into the single free-form grounding string
// the coach already accepts. Caller holds mu.
func (c *Controller) composedGroundingLocked() string {
g := c.knowledgeText
if c.carryForward != "" {
if g != "" {
g += "\n\n"
}
g += "Last session's takeaway: " + c.carryForward
}
return g
}
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted.
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()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
defer cancel()
prop, err := coach.Coach(ctx, intent, grounding)
c.mu.Lock()
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return // stale or left planning: discard
}
if err != nil {
c.coachStatus = coachError
c.coachErr = coachErrorMessage(err)
c.coachProposal = nil
} else {
c.coachStatus = coachReady
c.coachProposal = &prop
c.coachErr = ""
}
c.mu.Unlock()
c.notify()
}()
return nil
}
func coachErrorMessage(err error) string {
switch {
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
return "coach returned an unusable response"
case errors.Is(err, context.DeadlineExceeded):
return "coach timed out"
default:
return "coach unavailable"
}
}
// StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning ->
// Active.
@@ -1011,268 +339,3 @@ func (c *Controller) buildSummaryLocked() store.SessionSummary {
Buckets: buckets,
}
}
// RecordWindow ingests one sensor observation. It accumulates time only while
// Active, always tracks the latest window for display, and fires onChange after
// releasing the mutex.
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
c.mu.Lock()
c.latestWindow = snap
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
c.mu.Unlock()
c.notify()
return
}
now := c.clock()
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
c.applyEvent(now, snap)
c.recordTitleLocked(snap.Title)
launch := c.evaluateDriftLocked(now, snap)
enforceAct := c.enforceActionLocked()
c.mu.Unlock()
if launch != nil {
go launch()
}
if enforceAct != nil {
go enforceAct()
}
c.notify()
}
// enforceActionLocked returns the minimize thunk when this observation should be
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
// returned func performs blocking X11 I/O and MUST run after the caller releases
// c.mu, following the off-lock async-I/O discipline used by the other roles.
func (c *Controller) enforceActionLocked() func() {
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
return nil
}
guard := c.guard
return func() {
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
defer cancel()
if err := guard.MinimizeActive(ctx); err != nil {
log.Printf("session: enforce minimize failed: %v", err)
}
}
}
// evaluateDriftLocked runs the local-first drift pipeline for one observation
// and updates synchronous drift state. When an async judgment is warranted it
// returns the judging closure; the caller runs it in a goroutine after
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
// already verified the runtime is Active.
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
class, title := snap.Class, snap.Title
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
// where the semantic nudge runs — the drift judge stays silent here, so the
// two are mutually exclusive per observation.
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if evidence.MatchesAllowed(ac, class, title) {
c.driftStatus = driftOnTask
c.driftReason = ""
return c.maybeNudgeLocked(now)
}
// Past this point the window is not a local on-task match: the on-task
// stretch (if any) has ended. Void any soft nudge tied to it and advance the
// epoch so an in-flight nudge result is discarded rather than surfacing stale
// when the user returns.
c.nudgeMessage = ""
c.nudgeEpoch++
// 2. Per-class cache.
if v, ok := c.judgedClasses[class]; ok {
c.applyVerdictLocked(v)
return nil
}
// 3. No judge wired: never block; leave idle.
if c.judge == nil {
c.driftStatus = driftIdle
c.driftReason = ""
return nil
}
// 4. Debounce: at most one judgment per driftDebounce window.
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
return nil
}
prevStatus, prevReason := c.driftStatus, c.driftReason
c.driftGen++
gen := c.driftGen
c.lastJudgedAt = now
c.driftStatus = driftPending
c.driftReason = ""
judge := c.judge
commitment := c.commitmentLineLocked()
return func() {
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
defer cancel()
v, err := judge.JudgeDrift(ctx, commitment, class, title)
c.mu.Lock()
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: a newer judgment started or the session ended
}
if err != nil {
// Degrade: never fabricate drift. Revert the optimistic pending.
if c.driftStatus == driftPending {
c.driftStatus = prevStatus
c.driftReason = prevReason
}
c.mu.Unlock()
log.Printf("session: drift judge failed: %v", err)
c.notify()
return
}
c.judgedClasses[class] = v
var enforceAct func()
if c.stats != nil && c.stats.Current.Class == class {
c.applyVerdictLocked(v)
enforceAct = c.enforceActionLocked()
}
c.mu.Unlock()
if enforceAct != nil {
enforceAct()
}
c.notify()
}
}
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
// local-match path. It returns the nudging closure when eligible (judge wired,
// at least two titles of history, debounce elapsed), else nil. The closure
// follows the drift-judge discipline: it runs in a goroutine after the caller
// releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
// episode or a session change — is discarded rather than surfacing stale, never
// fabricates a concern on error, and notifies only after unlocking. Caller holds
// mu and has already verified the runtime is Active.
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
if c.nudge == nil || len(c.recentTitles) < 2 {
return nil
}
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
return nil
}
c.lastNudgedAt = now
epoch := c.nudgeEpoch
nudge := c.nudge
commitment := c.commitmentLineLocked()
titles := append([]string(nil), c.recentTitles...)
return func() {
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
defer cancel()
msg, err := nudge.Nudge(ctx, commitment, titles)
c.mu.Lock()
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: the on-task stretch ended (drift episode) or the session changed
}
if err != nil {
c.mu.Unlock()
log.Printf("session: nudge failed: %v", err)
return // never fabricate a concern; leave any prior message intact
}
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
c.mu.Unlock()
c.notify()
}
}
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
func (c *Controller) recordTitleLocked(title string) {
t := strings.TrimSpace(title)
if t == "" {
return
}
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
return
}
c.recentTitles = append(c.recentTitles, t)
if len(c.recentTitles) > recentTitlesMax {
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
}
}
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
if v.OnTask {
c.driftStatus = driftOnTask
c.driftReason = ""
return
}
c.driftStatus = driftDrifting
c.driftReason = v.Reason
}
// applyEvent advances stats by one observation: it credits the prior segment to
// the prior bucket, counts a context switch on key change, and records the new
// current window. Used by both live tracking and crash replay. Caller holds mu.
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
if c.stats.hasLast {
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
}
newKey := keyFor(snap)
if c.stats.hasLast && newKey != c.stats.lastKey {
c.stats.SwitchCount++
}
c.stats.lastKey = newKey
c.stats.lastFocusAt = now
c.stats.hasLast = true
c.stats.Current = snap
}
// replayStats rebuilds in-memory stats from the raw session log after a crash.
func (c *Controller) replayStats(sessionID string) {
events, err := store.ReplaySession(c.sessionsDir, sessionID)
if err != nil || len(events) == 0 {
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: c.clock().Unix(),
Buckets: map[bucketKey]time.Duration{},
}
return
}
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: events[0].AtUnixMillis / 1000,
Buckets: map[bucketKey]time.Duration{},
}
for _, e := range events {
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
}
}
func keyFor(snap evidence.WindowSnapshot) bucketKey {
if !snap.Health.Available {
return bucketKey{Class: "", Title: unavailableTitle}
}
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
}
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
return store.FocusEvent{
AtUnixMillis: now.UnixMilli(),
Class: snap.Class,
Title: snap.Title,
Available: snap.Health.Available,
Reason: snap.Health.Reason,
}
}
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
return evidence.WindowSnapshot{
Title: e.Title,
Class: e.Class,
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
}
}
+81
View File
@@ -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
}
+85
View File
@@ -0,0 +1,85 @@
package session
import (
"antidrift/internal/evidence"
"antidrift/internal/store"
"time"
)
// bucketKey identifies a time bucket; Title is the scrubbed title.
type bucketKey struct{ Class, Title string }
// EvidenceStats is the in-memory accounting for the current session only.
type EvidenceStats struct {
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
}
// applyEvent advances stats by one observation: it credits the prior segment to
// the prior bucket, counts a context switch on key change, and records the new
// current window. Used by both live tracking and crash replay. Caller holds mu.
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
if c.stats.hasLast {
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
}
newKey := keyFor(snap)
if c.stats.hasLast && newKey != c.stats.lastKey {
c.stats.SwitchCount++
}
c.stats.lastKey = newKey
c.stats.lastFocusAt = now
c.stats.hasLast = true
c.stats.Current = snap
}
// replayStats rebuilds in-memory stats from the raw session log after a crash.
func (c *Controller) replayStats(sessionID string) {
events, err := store.ReplaySession(c.sessionsDir, sessionID)
if err != nil || len(events) == 0 {
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: c.clock().Unix(),
Buckets: map[bucketKey]time.Duration{},
}
return
}
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: events[0].AtUnixMillis / 1000,
Buckets: map[bucketKey]time.Duration{},
}
for _, e := range events {
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
}
}
func keyFor(snap evidence.WindowSnapshot) bucketKey {
if !snap.Health.Available {
return bucketKey{Class: "", Title: unavailableTitle}
}
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
}
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
return store.FocusEvent{
AtUnixMillis: now.UnixMilli(),
Class: snap.Class,
Title: snap.Title,
Available: snap.Health.Available,
Reason: snap.Health.Reason,
}
}
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
return evidence.WindowSnapshot{
Title: e.Title,
Class: e.Class,
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
}
}
+189
View File
@@ -0,0 +1,189 @@
package session
import (
"antidrift/internal/domain"
"sort"
"time"
)
// CommitmentView is the UI projection of the active commitment.
type CommitmentView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
}
// ProposalView / CoachView project the ephemeral planning-coach state.
type ProposalView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
}
// DriftView is the active-only drift projection. Nudge is a separate axis from
// Status: it is populated precisely when Status is "ontask" (semantic drift
// inside an allowed app), so it cannot be folded into the status enum.
type DriftView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Nudge string `json:"nudge,omitempty"`
Enforced bool `json:"enforced,omitempty"`
}
type CoachView struct {
Status string `json:"status"`
Proposal *ProposalView `json:"proposal,omitempty"`
Error string `json:"error,omitempty"`
}
// TaskView is one to-do item in the planning tasks list.
type TaskView struct {
ID string `json:"id"`
Title string `json:"title"`
Day string `json:"day,omitempty"`
}
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
type TasksView struct {
Status string `json:"status"`
Tasks []TaskView `json:"tasks,omitempty"`
}
// KnowledgeView projects the ephemeral planning knowledge state (the standing
// profile that grounds the coach). The profile text is intentionally omitted —
// only its presence, source path, and size cross the wire.
type KnowledgeView struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
Chars int `json:"chars,omitempty"`
}
// ReflectionView projects the reviewer's output. Recap is shown on Review;
// CarryForward is shown on the next Planning screen. Unlike the knowledge
// profile, these short lines exist to be displayed, so they cross the wire.
type ReflectionView struct {
Status string `json:"status,omitempty"`
Recap string `json:"recap,omitempty"`
CarryForward string `json:"carry_forward,omitempty"`
}
// WindowView / BucketView / EvidenceView are the evidence projection.
type WindowView struct {
Class string `json:"class"`
Title string `json:"title"`
}
type BucketView struct {
Class string `json:"class"`
Title string `json:"title"`
Seconds int64 `json:"seconds"`
}
type EvidenceView struct {
Available bool `json:"available"`
Reason string `json:"reason"`
Current WindowView `json:"current"`
SwitchCount int `json:"switch_count"`
Buckets []BucketView `json:"buckets"`
}
// State is the broadcastable view of the controller.
type State struct {
RuntimeState domain.RuntimeState `json:"runtime_state"`
Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Reflection *ReflectionView `json:"reflection,omitempty"`
Drift *DriftView `json:"drift,omitempty"`
}
func (c *Controller) stateLocked() State {
st := State{RuntimeState: c.runtimeState}
if c.commitment != nil {
view := &CommitmentView{
NextAction: c.commitment.NextAction,
SuccessCondition: c.commitment.SuccessCondition,
TimeboxSecs: c.commitment.TimeboxSecs,
}
if !c.deadline.IsZero() {
view.DeadlineUnixSecs = c.deadline.Unix()
}
st.Commitment = view
}
if c.stats != nil {
st.Evidence = &EvidenceView{
Available: c.stats.Current.Health.Available,
Reason: c.stats.Current.Health.Reason,
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
SwitchCount: c.stats.SwitchCount,
Buckets: bucketViews(c.stats.Buckets),
}
}
if c.runtimeState == domain.RuntimePlanning {
status := c.coachStatus
if status == "" {
status = coachIdle
}
cv := &CoachView{Status: status, Error: c.coachErr}
if c.coachProposal != nil {
cv.Proposal = &ProposalView{
NextAction: c.coachProposal.NextAction,
SuccessCondition: c.coachProposal.SuccessCondition,
TimeboxSecs: c.coachProposal.TimeboxSecs,
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
}
}
st.Coach = cv
if c.tasksProvider != nil {
tstatus := c.tasksStatus
if tstatus == "" {
tstatus = tasksIdle
}
tv := &TasksView{Status: tstatus}
for _, t := range c.tasksList {
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
}
st.Tasks = tv
}
if c.knowledgeSrc != nil {
kstatus := c.knowledgeStatus
if kstatus == "" {
kstatus = knowledgeIdle
}
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
}
if c.carryForward != "" {
st.Reflection = &ReflectionView{CarryForward: c.carryForward}
}
}
if c.runtimeState == domain.RuntimeReview {
rstatus := c.reflectionStatus
if rstatus == "" {
rstatus = reflectionIdle
}
st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
}
if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus
if status == "" {
status = driftIdle
}
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
}
return st
}
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
out := make([]BucketView, 0, len(buckets))
for k, d := range buckets {
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
}
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
return out
}