From 622a1cd401a73e9e8fb9936a481c3e9f01413d2e Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 18:20:22 -0400 Subject: [PATCH] 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 --- .../2026-05-31-m3.5-semantic-nudge-design.md | 22 +++++++------ internal/session/session.go | 26 ++++++++++----- internal/session/session_test.go | 33 ++++++++++++++----- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md b/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md index 9366b67..0eecfe7 100644 --- a/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md +++ b/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md @@ -64,7 +64,7 @@ infrastructure. The `ai` package gains a third role (`Nudger`) but stays a **leaf package**: like `Coach` and `DriftJudge`, `Nudger` takes primitive strings, not `domain`/`evidence` types. `session.Controller` orchestrates the debounced async nudge with the *exact same discipline* as the M3 drift judge — -debounce, session-generation guard, goroutine launched after releasing the +debounce, on-task-stretch epoch guard, goroutine launched after releasing the mutex, `notify()` only with the mutex released, never fabricate on error. ### The `ai.Nudger` role @@ -109,15 +109,17 @@ New fields on `Controller` (all reset per session in `resetDriftLocked`): - `nudgeMessage string` — the current soft advisory ("" = none). - `lastNudgedAt time.Time` — debounce timestamp. -The nudge needs a session-identity guard, but `driftGen` cannot serve it: -`driftGen` is bumped on every drift-judgment launch (not only at session -boundaries), so an interleaved drift judgment within the same session would -wrongly discard a valid in-flight nudge. The nudge therefore uses a dedicated -`sessionGen` counter, incremented only in `resetDriftLocked`, so it survives an -interleaved drift judgment yet is still discarded when the session ends. A nudge -in flight when the user clicks "This is on task" / "Back to task" likewise -remains valid — those actions do not change `sessionGen` and the nudge is about -the session trajectory, not a single class. +A soft nudge advisory belongs to one continuous on-task stretch in an allowed +app, so the nudge is guarded by an on-task-stretch epoch (`nudgeEpoch`) rather +than `driftGen` (which bumps on every drift-judgment launch). `nudgeEpoch` +advances on session reset (`resetDriftLocked`) and whenever an observation is +not a local on-task match — i.e. the stretch ended. A nudge captures the epoch +at launch and applies its result only if the epoch is unchanged, so a nudge +whose stretch has since ended (a drift episode or a session change) is discarded +instead of surfacing stale. Leaving the allowed app additionally clears any +already-set advisory. The net effect: the advisory auto-clears when the +trajectory changes or recovers, and a stale "Heads up" never resurfaces after a +drift episode. New constants alongside the drift ones: diff --git a/internal/session/session.go b/internal/session/session.go index 170071d..5091df5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -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() diff --git a/internal/session/session_test.go b/internal/session/session_test.go index eb3c632..b650689 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -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) {