Minimize the off-task window on confirmed drift at the block level
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/enforce"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
"antidrift/internal/knowledge"
|
"antidrift/internal/knowledge"
|
||||||
"antidrift/internal/statemachine"
|
"antidrift/internal/statemachine"
|
||||||
@@ -75,10 +76,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
driftDebounce = 10 * time.Second
|
driftDebounce = 10 * time.Second
|
||||||
driftTimeout = 30 * time.Second
|
driftTimeout = 30 * time.Second
|
||||||
nudgeDebounce = 5 * time.Minute
|
enforceTimeout = 5 * time.Second
|
||||||
nudgeTimeout = 30 * time.Second
|
nudgeDebounce = 5 * time.Minute
|
||||||
|
nudgeTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
const recentTitlesMax = 10
|
const recentTitlesMax = 10
|
||||||
@@ -149,6 +151,7 @@ type Controller struct {
|
|||||||
|
|
||||||
allowedClasses []string // durable: the active session's allowed window classes
|
allowedClasses []string // durable: the active session's allowed window classes
|
||||||
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
|
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
|
||||||
|
guard enforce.Guard
|
||||||
judge ai.DriftJudge
|
judge ai.DriftJudge
|
||||||
driftStatus string
|
driftStatus string
|
||||||
driftReason string
|
driftReason string
|
||||||
@@ -184,9 +187,10 @@ type ProposalView struct {
|
|||||||
// Status: it is populated precisely when Status is "ontask" (semantic drift
|
// Status: it is populated precisely when Status is "ontask" (semantic drift
|
||||||
// inside an allowed app), so it cannot be folded into the status enum.
|
// inside an allowed app), so it cannot be folded into the status enum.
|
||||||
type DriftView struct {
|
type DriftView struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Nudge string `json:"nudge,omitempty"`
|
Nudge string `json:"nudge,omitempty"`
|
||||||
|
Enforced bool `json:"enforced,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoachView struct {
|
type CoachView struct {
|
||||||
@@ -407,7 +411,8 @@ func (c *Controller) stateLocked() State {
|
|||||||
if status == "" {
|
if status == "" {
|
||||||
status = driftIdle
|
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
|
return st
|
||||||
}
|
}
|
||||||
@@ -720,6 +725,14 @@ func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
|||||||
c.mu.Unlock()
|
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;
|
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
||||||
// local matching and the drift judge are unaffected.
|
// local matching and the drift judge are unaffected.
|
||||||
func (c *Controller) SetNudge(n ai.Nudger) {
|
func (c *Controller) SetNudge(n ai.Nudger) {
|
||||||
@@ -1015,13 +1028,35 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|||||||
c.applyEvent(now, snap)
|
c.applyEvent(now, snap)
|
||||||
c.recordTitleLocked(snap.Title)
|
c.recordTitleLocked(snap.Title)
|
||||||
launch := c.evaluateDriftLocked(now, snap)
|
launch := c.evaluateDriftLocked(now, snap)
|
||||||
|
enforceAct := c.enforceActionLocked()
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
if launch != nil {
|
if launch != nil {
|
||||||
go launch()
|
go launch()
|
||||||
}
|
}
|
||||||
|
if enforceAct != nil {
|
||||||
|
go enforceAct()
|
||||||
|
}
|
||||||
c.notify()
|
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
|
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
||||||
// and updates synchronous drift state. When an async judgment is warranted it
|
// and updates synchronous drift state. When an async judgment is warranted it
|
||||||
// returns the judging closure; the caller runs it in a goroutine after
|
// 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
|
return
|
||||||
}
|
}
|
||||||
c.judgedClasses[class] = v
|
c.judgedClasses[class] = v
|
||||||
|
var enforceAct func()
|
||||||
if c.stats != nil && c.stats.Current.Class == class {
|
if c.stats != nil && c.stats.Current.Class == class {
|
||||||
c.applyVerdictLocked(v)
|
c.applyVerdictLocked(v)
|
||||||
|
enforceAct = c.enforceActionLocked()
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
if enforceAct != nil {
|
||||||
|
enforceAct()
|
||||||
|
}
|
||||||
c.notify()
|
c.notify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -558,6 +558,91 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
|
|||||||
waitDriftStatus(t, second, "drifting")
|
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) {
|
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
|
|||||||
Reference in New Issue
Block a user