0d3294b30d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
327 lines
9.7 KiB
Go
327 lines
9.7 KiB
Go
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
|
|
}
|