Compare commits
11 Commits
5adab985a7
...
895ad342c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 895ad342c7 | |||
| 59283be733 | |||
| fbbff6966d | |||
| db0d11ea2e | |||
| d3f0526355 | |||
| 0d3294b30d | |||
| d068e2f79c | |||
| a0f144968b | |||
| f13f33cf74 | |||
| 465938906e | |||
| 79b3c0be95 |
@@ -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 M0–M8 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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user