diff --git a/internal/session/drift.go b/internal/session/drift.go new file mode 100644 index 0000000..b8931aa --- /dev/null +++ b/internal/session/drift.go @@ -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 +} diff --git a/internal/session/session.go b/internal/session/session.go index 9ba98e5..c9870b1 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -75,23 +75,6 @@ const ( 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") @@ -520,100 +503,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. @@ -814,205 +703,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 -}