From 2977b903c2bc641a391ce9895136dd3794e29de5 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 16:53:23 -0400 Subject: [PATCH] Add drift state, persistence, and override controls to controller Co-Authored-By: Claude Opus 4.8 --- internal/session/session.go | 119 +++++++++++++++++++++++++++++-- internal/session/session_test.go | 65 ++++++++++++++--- internal/store/store.go | 2 + internal/web/web.go | 2 +- 4 files changed, 174 insertions(+), 14 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 8e0e376..0cce2c2 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -10,6 +10,7 @@ import ( "log" "path/filepath" "sort" + "strings" "sync" "time" @@ -36,8 +37,22 @@ const ( coachError = "error" ) +const ( + driftDebounce = 10 * time.Second + driftTimeout = 30 * time.Second +) + +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 } @@ -72,6 +87,14 @@ type Controller struct { coachProposal *ai.Proposal coachErr string coachGen int + + allowedClasses []string // durable: the active session's allowed window classes + judge ai.DriftJudge + driftStatus string + driftReason string + driftGen int + lastJudgedAt time.Time + judgedClasses map[string]ai.Verdict } // CommitmentView is the UI projection of the active commitment. @@ -87,6 +110,14 @@ 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. +type DriftView struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` } type CoachView struct { @@ -121,6 +152,7 @@ type State struct { Commitment *CommitmentView `json:"commitment"` Evidence *EvidenceView `json:"evidence"` Coach *CoachView `json:"coach,omitempty"` + Drift *DriftView `json:"drift,omitempty"` } // New loads any persisted snapshot, prunes stale session logs, and rebuilds @@ -148,6 +180,7 @@ func New(snapshotPath string) (*Controller, error) { } _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock()) if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { + c.allowedClasses = s.AllowedWindowClasses c.replayStats(s.SessionID) } return c, nil @@ -222,13 +255,21 @@ func (c *Controller) stateLocked() State { 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, + NextAction: c.coachProposal.NextAction, + SuccessCondition: c.coachProposal.SuccessCondition, + TimeboxSecs: c.coachProposal.TimeboxSecs, + AllowedWindowClasses: c.coachProposal.AllowedWindowClasses, } } st.Coach = cv } + if c.runtimeState == domain.RuntimeActive { + status := c.driftStatus + if status == "" { + status = driftIdle + } + st.Drift = &DriftView{Status: status, Reason: c.driftReason} + } return st } @@ -253,6 +294,7 @@ func (c *Controller) persistLocked() error { if c.stats != nil { snap.SessionID = c.stats.SessionID } + snap.AllowedWindowClasses = c.allowedClasses return store.Save(c.snapshotPath, snap) } @@ -286,6 +328,71 @@ func (c *Controller) resetCoachLocked() { c.coachGen++ } +// AllowedClassesForTest exposes the session allowed classes for tests. +func (c *Controller) AllowedClassesForTest() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.allowedClasses...) +} + +// 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() +} + +// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu. +func (c *Controller) resetDriftLocked() { + c.driftStatus = driftIdle + c.driftReason = "" + c.driftGen++ + c.lastJudgedAt = time.Time{} + c.judgedClasses = map[string]ai.Verdict{} +} + +// 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() +} + // 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. @@ -351,7 +458,7 @@ func coachErrorMessage(err error) string { // StartManualCommitment validates input, activates a new commitment, mints a // session, seeds evidence stats from the latest window, and moves Planning -> // Active. -func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error { +func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error { c.mu.Lock() defer c.mu.Unlock() commitment, err := domain.NewManual(nextAction, successCondition, timebox) @@ -372,6 +479,8 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string, c.deadline = now.Add(timebox) c.outcomePending = "" c.resetCoachLocked() + c.allowedClasses = append([]string(nil), allowedClasses...) + c.resetDriftLocked() sessionID := "session-" + uuid.Must(uuid.NewV7()).String() c.stats = &EvidenceStats{ @@ -436,6 +545,8 @@ func (c *Controller) End() error { c.deadline = time.Time{} c.stats = nil c.outcomePending = "" + c.allowedClasses = nil + c.resetDriftLocked() return c.persistLocked() } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 16e3cd6..5ca0c8d 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -31,7 +31,7 @@ func TestHappyPathDrivesStates(t *testing.T) { if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } - if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil { + if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil { t.Fatalf("start commitment: %v", err) } st := c.State() @@ -61,7 +61,7 @@ func TestHappyPathDrivesStates(t *testing.T) { func TestStartCommitmentRejectsInvalidInput(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() - if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil { + if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil { t.Fatalf("empty next action should error") } if c.State().RuntimeState != domain.RuntimePlanning { @@ -76,7 +76,7 @@ func TestStateRestoresFromSnapshot(t *testing.T) { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() - _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute) + _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil) second, err := New(path) if err != nil { @@ -98,7 +98,7 @@ func TestRestoredCommitmentKeepsDeadline(t *testing.T) { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() - _ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute) + _ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil) want := first.State().Commitment.DeadlineUnixSecs second, err := New(path) @@ -141,7 +141,7 @@ func TestAccumulatesBucketsAndSwitches(t *testing.T) { c.RecordWindow(snap("code", "antidrift")) // latest window before start _ = c.EnterPlanning() - _ = c.StartManualCommitment("work", "done", 25*time.Minute) // seeds at t=1000 with code/antidrift + _ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift clk.advance(10 * time.Second) c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox @@ -176,7 +176,7 @@ func TestUnavailableAccruesToReservedBucket(t *testing.T) { clk := &fakeClock{now: time.Unix(1000, 0)} c.SetClock(clk.fn()) _ = c.EnterPlanning() - _ = c.StartManualCommitment("work", "done", 25*time.Minute) // seed: empty unavailable latestWindow + _ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow // latestWindow was zero-value (unavailable) at seed time. clk.advance(5 * time.Second) c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket @@ -193,7 +193,7 @@ func TestCrashReplayRebuildsStats(t *testing.T) { first.SetClock(clk.fn()) first.RecordWindow(snap("code", "antidrift")) _ = first.EnterPlanning() - _ = first.StartManualCommitment("work", "done", 25*time.Minute) + _ = first.StartManualCommitment("work", "done", 25*time.Minute, nil) clk.advance(15 * time.Second) first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch @@ -222,7 +222,7 @@ func TestEndWritesAuditSummary(t *testing.T) { c.SetClock(clk.fn()) c.RecordWindow(snap("code", "antidrift")) _ = c.EnterPlanning() - _ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute) + _ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil) clk.advance(30 * time.Second) c.RecordWindow(snap("firefox", "docs")) clk.advance(10 * time.Second) @@ -339,13 +339,60 @@ func TestRequestCoachStaleResultDiscarded(t *testing.T) { } } +func TestOnTaskRequiresActive(t *testing.T) { + c, _ := newTestController(t) + if err := c.OnTask(); !errors.Is(err, ErrNotActive) { + t.Fatalf("OnTask outside Active should error, got %v", err) + } + if err := c.Refocus(); !errors.Is(err, ErrNotActive) { + t.Fatalf("Refocus outside Active should error, got %v", err) + } +} + +func TestStartCommitmentPersistsAllowedClasses(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + first, err := New(path) + if err != nil { + t.Fatalf("new: %v", err) + } + _ = first.EnterPlanning() + if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil { + t.Fatalf("start: %v", err) + } + second, err := New(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if got := second.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" { + t.Fatalf("allowed classes not restored: %#v", got) + } +} + +func TestOnTaskAppendsCurrentClass(t *testing.T) { + c, _ := newTestController(t) + _ = c.EnterPlanning() + _ = c.StartManualCommitment("a", "b", 25*time.Minute, nil) + c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}}) + if err := c.OnTask(); err != nil { + t.Fatalf("ontask: %v", err) + } + got := c.AllowedClassesForTest() + if len(got) != 1 || got[0] != "slack" { + t.Fatalf("OnTask should append current class, got %#v", got) + } + st := c.State() + if st.Drift == nil || st.Drift.Status != "ontask" { + t.Fatalf("OnTask should set drift ontask, got %+v", st.Drift) + } +} + func TestLeavingPlanningClearsCoach(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}}) _ = c.RequestCoach("x") waitCoachStatus(t, c, "ready") - if err := c.StartManualCommitment("a", "b", 25*time.Minute); err != nil { + if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil { t.Fatalf("start: %v", err) } if c.State().Coach != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 74b04fb..7baa1b8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -19,6 +19,8 @@ type Snapshot struct { DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"` SessionID string `json:"session_id,omitempty"` OutcomePending string `json:"outcome_pending,omitempty"` + + AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` } // DefaultPath returns ~/.antidrift/state.json. diff --git a/internal/web/web.go b/internal/web/web.go index 3087bdc..891d079 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -108,7 +108,7 @@ func (s *Server) handleCommitment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) return } - err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second) + err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, nil) if err == nil { s.armExpiry() }