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
+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) {