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"
"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()
}