Add drift state, persistence, and override controls to controller

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 16:53:23 -04:00
parent 75d31b5fa8
commit 2977b903c2
4 changed files with 174 additions and 14 deletions
+115 -4
View File
@@ -10,6 +10,7 @@ import (
"log" "log"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
@@ -36,8 +37,22 @@ const (
coachError = "error" 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 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. // bucketKey identifies a time bucket; Title is the scrubbed title.
type bucketKey struct{ Class, Title string } type bucketKey struct{ Class, Title string }
@@ -72,6 +87,14 @@ type Controller struct {
coachProposal *ai.Proposal coachProposal *ai.Proposal
coachErr string coachErr string
coachGen int 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. // CommitmentView is the UI projection of the active commitment.
@@ -87,6 +110,14 @@ type ProposalView struct {
NextAction string `json:"next_action"` NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"` SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"` 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 { type CoachView struct {
@@ -121,6 +152,7 @@ type State struct {
Commitment *CommitmentView `json:"commitment"` Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"` Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"` Coach *CoachView `json:"coach,omitempty"`
Drift *DriftView `json:"drift,omitempty"`
} }
// New loads any persisted snapshot, prunes stale session logs, and rebuilds // 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()) _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
c.allowedClasses = s.AllowedWindowClasses
c.replayStats(s.SessionID) c.replayStats(s.SessionID)
} }
return c, nil return c, nil
@@ -222,13 +255,21 @@ func (c *Controller) stateLocked() State {
cv := &CoachView{Status: status, Error: c.coachErr} cv := &CoachView{Status: status, Error: c.coachErr}
if c.coachProposal != nil { if c.coachProposal != nil {
cv.Proposal = &ProposalView{ cv.Proposal = &ProposalView{
NextAction: c.coachProposal.NextAction, NextAction: c.coachProposal.NextAction,
SuccessCondition: c.coachProposal.SuccessCondition, SuccessCondition: c.coachProposal.SuccessCondition,
TimeboxSecs: c.coachProposal.TimeboxSecs, TimeboxSecs: c.coachProposal.TimeboxSecs,
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
} }
} }
st.Coach = cv 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 return st
} }
@@ -253,6 +294,7 @@ func (c *Controller) persistLocked() error {
if c.stats != nil { if c.stats != nil {
snap.SessionID = c.stats.SessionID snap.SessionID = c.stats.SessionID
} }
snap.AllowedWindowClasses = c.allowedClasses
return store.Save(c.snapshotPath, snap) return store.Save(c.snapshotPath, snap)
} }
@@ -286,6 +328,71 @@ func (c *Controller) resetCoachLocked() {
c.coachGen++ 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 // RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach // not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted. // 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 // StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning -> // session, seeds evidence stats from the latest window, and moves Planning ->
// Active. // 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
commitment, err := domain.NewManual(nextAction, successCondition, timebox) commitment, err := domain.NewManual(nextAction, successCondition, timebox)
@@ -372,6 +479,8 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
c.deadline = now.Add(timebox) c.deadline = now.Add(timebox)
c.outcomePending = "" c.outcomePending = ""
c.resetCoachLocked() c.resetCoachLocked()
c.allowedClasses = append([]string(nil), allowedClasses...)
c.resetDriftLocked()
sessionID := "session-" + uuid.Must(uuid.NewV7()).String() sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
c.stats = &EvidenceStats{ c.stats = &EvidenceStats{
@@ -436,6 +545,8 @@ func (c *Controller) End() error {
c.deadline = time.Time{} c.deadline = time.Time{}
c.stats = nil c.stats = nil
c.outcomePending = "" c.outcomePending = ""
c.allowedClasses = nil
c.resetDriftLocked()
return c.persistLocked() return c.persistLocked()
} }
+56 -9
View File
@@ -31,7 +31,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
if err := c.EnterPlanning(); err != nil { if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err) 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) t.Fatalf("start commitment: %v", err)
} }
st := c.State() st := c.State()
@@ -61,7 +61,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
func TestStartCommitmentRejectsInvalidInput(t *testing.T) { func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
c, _ := newTestController(t) c, _ := newTestController(t)
_ = c.EnterPlanning() _ = 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") t.Fatalf("empty next action should error")
} }
if c.State().RuntimeState != domain.RuntimePlanning { if c.State().RuntimeState != domain.RuntimePlanning {
@@ -76,7 +76,7 @@ func TestStateRestoresFromSnapshot(t *testing.T) {
t.Fatalf("new: %v", err) t.Fatalf("new: %v", err)
} }
_ = first.EnterPlanning() _ = first.EnterPlanning()
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute) _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil)
second, err := New(path) second, err := New(path)
if err != nil { if err != nil {
@@ -98,7 +98,7 @@ func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
t.Fatalf("new: %v", err) t.Fatalf("new: %v", err)
} }
_ = first.EnterPlanning() _ = first.EnterPlanning()
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute) _ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil)
want := first.State().Commitment.DeadlineUnixSecs want := first.State().Commitment.DeadlineUnixSecs
second, err := New(path) second, err := New(path)
@@ -141,7 +141,7 @@ func TestAccumulatesBucketsAndSwitches(t *testing.T) {
c.RecordWindow(snap("code", "antidrift")) // latest window before start c.RecordWindow(snap("code", "antidrift")) // latest window before start
_ = c.EnterPlanning() _ = 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) clk.advance(10 * time.Second)
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox 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)} clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn()) c.SetClock(clk.fn())
_ = c.EnterPlanning() _ = 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. // latestWindow was zero-value (unavailable) at seed time.
clk.advance(5 * time.Second) clk.advance(5 * time.Second)
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket 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.SetClock(clk.fn())
first.RecordWindow(snap("code", "antidrift")) first.RecordWindow(snap("code", "antidrift"))
_ = first.EnterPlanning() _ = first.EnterPlanning()
_ = first.StartManualCommitment("work", "done", 25*time.Minute) _ = first.StartManualCommitment("work", "done", 25*time.Minute, nil)
clk.advance(15 * time.Second) clk.advance(15 * time.Second)
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
@@ -222,7 +222,7 @@ func TestEndWritesAuditSummary(t *testing.T) {
c.SetClock(clk.fn()) c.SetClock(clk.fn())
c.RecordWindow(snap("code", "antidrift")) c.RecordWindow(snap("code", "antidrift"))
_ = c.EnterPlanning() _ = 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) clk.advance(30 * time.Second)
c.RecordWindow(snap("firefox", "docs")) c.RecordWindow(snap("firefox", "docs"))
clk.advance(10 * time.Second) 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) { func TestLeavingPlanningClearsCoach(t *testing.T) {
c, _ := newTestController(t) c, _ := newTestController(t)
_ = c.EnterPlanning() _ = c.EnterPlanning()
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}}) c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
_ = c.RequestCoach("x") _ = c.RequestCoach("x")
waitCoachStatus(t, c, "ready") 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) t.Fatalf("start: %v", err)
} }
if c.State().Coach != nil { if c.State().Coach != nil {
+2
View File
@@ -19,6 +19,8 @@ type Snapshot struct {
DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"` DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"`
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
OutcomePending string `json:"outcome_pending,omitempty"` OutcomePending string `json:"outcome_pending,omitempty"`
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
} }
// DefaultPath returns ~/.antidrift/state.json. // DefaultPath returns ~/.antidrift/state.json.
+1 -1
View File
@@ -108,7 +108,7 @@ func (s *Server) handleCommitment(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return 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 { if err == nil {
s.armExpiry() s.armExpiry()
} }