Tie nudge validity to the on-task stretch

A soft nudge advisory belongs to one continuous on-task stretch in an allowed
app. Guard it with an on-task-stretch epoch (advanced on session reset and on
any non-on-task-match observation) instead of a session-only counter, and clear
the advisory on leaving the allowed app, so a stale 'Heads up' never resurfaces
after a drift episode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 18:20:22 -04:00
parent 9dd0bb934e
commit 622a1cd401
3 changed files with 54 additions and 27 deletions
+17 -9
View File
@@ -97,7 +97,7 @@ type Controller struct {
driftStatus string
driftReason string
driftGen int
sessionGen int // bumped only at session boundaries; nudge staleness guard
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
lastJudgedAt time.Time
judgedClasses map[string]ai.Verdict
@@ -389,7 +389,7 @@ func (c *Controller) resetDriftLocked() {
c.driftStatus = driftIdle
c.driftReason = ""
c.driftGen++
c.sessionGen++
c.nudgeEpoch++
c.lastJudgedAt = time.Time{}
c.judgedClasses = map[string]ai.Verdict{}
c.recentTitles = nil
@@ -662,6 +662,13 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
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)
@@ -723,10 +730,11 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
// 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 sessionGen (a session-only
// counter, so an interleaved drift judgment within the same session does not
// discard the nudge), never fabricates a concern on error, and notifies only
// after unlocking. Caller holds mu and has already verified the runtime is Active.
// 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
@@ -735,7 +743,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
return nil
}
c.lastNudgedAt = now
gen := c.sessionGen
epoch := c.nudgeEpoch
nudge := c.nudge
commitment := c.commitmentLineLocked()
titles := append([]string(nil), c.recentTitles...)
@@ -746,9 +754,9 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
msg, err := nudge.Nudge(ctx, commitment, titles)
c.mu.Lock()
if gen != c.sessionGen || c.runtimeState != domain.RuntimeActive {
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: a new session started or the session ended
return // stale: the on-task stretch ended (drift episode) or the session changed
}
if err != nil {
c.mu.Unlock()
+25 -8
View File
@@ -693,11 +693,26 @@ func TestNilNudgerStaysSilentOnTask(t *testing.T) {
}
}
func TestNudgeSurvivesInterleavedDriftJudgment(t *testing.T) {
func TestNudgeClearedOnLeavingAllowedApp(t *testing.T) {
c, _ := newTestController(t)
fn := &fakeNudger{msg: "kept", gate: make(chan struct{})}
c.SetNudge(&fakeNudger{msg: "drifting within editor"})
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "a.go"))
c.RecordWindow(obs("code", "b.go"))
waitNudge(t, c, "drifting within editor")
// Leaving the allowed app ends the on-task stretch and voids the advisory.
c.RecordWindow(obs("firefox", "YouTube"))
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
t.Fatalf("advisory must clear when leaving the allowed app: %+v", st.Drift)
}
}
func TestNudgeInFlightDiscardedAfterDriftEpisode(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
fn := &fakeNudger{msg: "stale", gate: make(chan struct{})}
c.SetNudge(fn)
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "a.go"))
c.RecordWindow(obs("code", "b.go")) // nudge launches, blocks on gate
@@ -705,11 +720,13 @@ func TestNudgeSurvivesInterleavedDriftJudgment(t *testing.T) {
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
time.Sleep(5 * time.Millisecond)
}
// An interleaved drift judgment bumps driftGen; the nudge must NOT be
// discarded by it — it is guarded by session identity, not driftGen.
c.RecordWindow(obs("firefox", "YouTube"))
close(fn.gate) // nudge returns within the same session
waitNudge(t, c, "kept")
c.RecordWindow(obs("firefox", "YouTube")) // ends the on-task stretch -> epoch++
close(fn.gate) // in-flight nudge returns now
c.RecordWindow(obs("code", "c.go")) // back on task (debounced; no new nudge)
time.Sleep(50 * time.Millisecond)
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
t.Fatalf("in-flight nudge from an ended stretch must not surface: %+v", st.Drift)
}
}
func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {