diff --git a/internal/session/session.go b/internal/session/session.go index 124602e..93fa3b0 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -17,6 +17,7 @@ import ( "antidrift/internal/ai" "antidrift/internal/domain" + "antidrift/internal/enforce" "antidrift/internal/evidence" "antidrift/internal/knowledge" "antidrift/internal/statemachine" @@ -75,10 +76,11 @@ const ( ) const ( - driftDebounce = 10 * time.Second - driftTimeout = 30 * time.Second - nudgeDebounce = 5 * time.Minute - nudgeTimeout = 30 * time.Second + driftDebounce = 10 * time.Second + driftTimeout = 30 * time.Second + enforceTimeout = 5 * time.Second + nudgeDebounce = 5 * time.Minute + nudgeTimeout = 30 * time.Second ) const recentTitlesMax = 10 @@ -149,6 +151,7 @@ type Controller struct { allowedClasses []string // durable: the active session's allowed window classes enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement + guard enforce.Guard judge ai.DriftJudge driftStatus string driftReason string @@ -184,9 +187,10 @@ type ProposalView struct { // Status: it is populated precisely when Status is "ontask" (semantic drift // inside an allowed app), so it cannot be folded into the status enum. type DriftView struct { - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Nudge string `json:"nudge,omitempty"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Nudge string `json:"nudge,omitempty"` + Enforced bool `json:"enforced,omitempty"` } type CoachView struct { @@ -407,7 +411,8 @@ func (c *Controller) stateLocked() State { if status == "" { status = driftIdle } - st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage} + enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting + st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced} } return st } @@ -720,6 +725,14 @@ func (c *Controller) SetDriftJudge(j ai.DriftJudge) { 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) { @@ -1015,13 +1028,35 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) { 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 @@ -1096,10 +1131,15 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap 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() } } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 60d6d52..16d1b62 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -558,6 +558,91 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) { waitDriftStatus(t, second, "drifting") } +type fakeGuard struct{ calls int32 } + +func (g *fakeGuard) MinimizeActive(context.Context) error { + atomic.AddInt32(&g.calls, 1) + return nil +} + +func waitGuardCalls(t *testing.T, g *fakeGuard, min int32) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if atomic.LoadInt32(&g.calls) >= min { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("guard minimize calls never reached %d (got %d)", min, atomic.LoadInt32(&g.calls)) +} + +func TestDriftMinimizesAtBlockViaBothPaths(t *testing.T) { + c, _ := newTestController(t) + c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) + g := &fakeGuard{} + c.SetGuard(g) + startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) + + // Async path: first off-task observation launches the judge; on confirmation + // the guard minimizes once. + c.RecordWindow(obs("firefox", "YouTube")) + st := waitDriftStatus(t, c, "drifting") + if !st.Drift.Enforced { + t.Fatalf("Enforced should be true while drifting at block: %+v", st.Drift) + } + waitGuardCalls(t, g, 1) + + // Synchronous cached path: same class again hits the per-class cache, sets + // drifting under the lock, and minimizes again without a new judgment. + c.RecordWindow(obs("firefox", "Reddit")) + waitGuardCalls(t, g, 2) + if got := atomic.LoadInt32(&g.calls); got < 2 { + t.Fatalf("cached-drift path did not minimize: %d calls", got) + } +} + +func TestWarnLevelNeverMinimizes(t *testing.T) { + c, _ := newTestController(t) + c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) + g := &fakeGuard{} + c.SetGuard(g) + startActiveLevel(t, c, []string{"code"}, domain.EnforcementWarn) + + c.RecordWindow(obs("firefox", "YouTube")) + st := waitDriftStatus(t, c, "drifting") + if st.Drift.Enforced { + t.Fatalf("Enforced must be false at warn: %+v", st.Drift) + } + time.Sleep(50 * time.Millisecond) // allow any stray enforcement goroutine to run + if got := atomic.LoadInt32(&g.calls); got != 0 { + t.Fatalf("warn level must not minimize, got %d calls", got) + } +} + +func TestOnTaskNeverMinimizes(t *testing.T) { + c, _ := newTestController(t) + c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}}) + g := &fakeGuard{} + c.SetGuard(g) + startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) + + c.RecordWindow(obs("code", "main.go")) // local on-task match + time.Sleep(50 * time.Millisecond) + if got := atomic.LoadInt32(&g.calls); got != 0 { + t.Fatalf("on-task must not minimize, got %d calls", got) + } +} + +func TestBlockWithoutGuardDoesNotPanic(t *testing.T) { + c, _ := newTestController(t) + c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) + // No guard wired. + startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) + c.RecordWindow(obs("firefox", "YouTube")) + waitDriftStatus(t, c, "drifting") // must reach drifting without panicking +} + func TestLeavingPlanningClearsCoach(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning()