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:
@@ -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
|
**leaf package**: like `Coach` and `DriftJudge`, `Nudger` takes primitive
|
||||||
strings, not `domain`/`evidence` types. `session.Controller` orchestrates the
|
strings, not `domain`/`evidence` types. `session.Controller` orchestrates the
|
||||||
debounced async nudge with the *exact same discipline* as the M3 drift judge —
|
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.
|
mutex, `notify()` only with the mutex released, never fabricate on error.
|
||||||
|
|
||||||
### The `ai.Nudger` role
|
### 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).
|
- `nudgeMessage string` — the current soft advisory ("" = none).
|
||||||
- `lastNudgedAt time.Time` — debounce timestamp.
|
- `lastNudgedAt time.Time` — debounce timestamp.
|
||||||
|
|
||||||
The nudge needs a session-identity guard, but `driftGen` cannot serve it:
|
A soft nudge advisory belongs to one continuous on-task stretch in an allowed
|
||||||
`driftGen` is bumped on every drift-judgment launch (not only at session
|
app, so the nudge is guarded by an on-task-stretch epoch (`nudgeEpoch`) rather
|
||||||
boundaries), so an interleaved drift judgment within the same session would
|
than `driftGen` (which bumps on every drift-judgment launch). `nudgeEpoch`
|
||||||
wrongly discard a valid in-flight nudge. The nudge therefore uses a dedicated
|
advances on session reset (`resetDriftLocked`) and whenever an observation is
|
||||||
`sessionGen` counter, incremented only in `resetDriftLocked`, so it survives an
|
not a local on-task match — i.e. the stretch ended. A nudge captures the epoch
|
||||||
interleaved drift judgment yet is still discarded when the session ends. A nudge
|
at launch and applies its result only if the epoch is unchanged, so a nudge
|
||||||
in flight when the user clicks "This is on task" / "Back to task" likewise
|
whose stretch has since ended (a drift episode or a session change) is discarded
|
||||||
remains valid — those actions do not change `sessionGen` and the nudge is about
|
instead of surfacing stale. Leaving the allowed app additionally clears any
|
||||||
the session trajectory, not a single class.
|
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:
|
New constants alongside the drift ones:
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ type Controller struct {
|
|||||||
driftStatus string
|
driftStatus string
|
||||||
driftReason string
|
driftReason string
|
||||||
driftGen int
|
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
|
lastJudgedAt time.Time
|
||||||
judgedClasses map[string]ai.Verdict
|
judgedClasses map[string]ai.Verdict
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ func (c *Controller) resetDriftLocked() {
|
|||||||
c.driftStatus = driftIdle
|
c.driftStatus = driftIdle
|
||||||
c.driftReason = ""
|
c.driftReason = ""
|
||||||
c.driftGen++
|
c.driftGen++
|
||||||
c.sessionGen++
|
c.nudgeEpoch++
|
||||||
c.lastJudgedAt = time.Time{}
|
c.lastJudgedAt = time.Time{}
|
||||||
c.judgedClasses = map[string]ai.Verdict{}
|
c.judgedClasses = map[string]ai.Verdict{}
|
||||||
c.recentTitles = nil
|
c.recentTitles = nil
|
||||||
@@ -662,6 +662,13 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
|
|||||||
return c.maybeNudgeLocked(now)
|
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.
|
// 2. Per-class cache.
|
||||||
if v, ok := c.judgedClasses[class]; ok {
|
if v, ok := c.judgedClasses[class]; ok {
|
||||||
c.applyVerdictLocked(v)
|
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,
|
// local-match path. It returns the nudging closure when eligible (judge wired,
|
||||||
// at least two titles of history, debounce elapsed), else nil. The closure
|
// 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
|
// 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
|
// releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current
|
||||||
// counter, so an interleaved drift judgment within the same session does not
|
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
|
||||||
// discard the nudge), never fabricates a concern on error, and notifies only
|
// episode or a session change — is discarded rather than surfacing stale, never
|
||||||
// after unlocking. Caller holds mu and has already verified the runtime is Active.
|
// 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() {
|
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
||||||
if c.nudge == nil || len(c.recentTitles) < 2 {
|
if c.nudge == nil || len(c.recentTitles) < 2 {
|
||||||
return nil
|
return nil
|
||||||
@@ -735,7 +743,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c.lastNudgedAt = now
|
c.lastNudgedAt = now
|
||||||
gen := c.sessionGen
|
epoch := c.nudgeEpoch
|
||||||
nudge := c.nudge
|
nudge := c.nudge
|
||||||
commitment := c.commitmentLineLocked()
|
commitment := c.commitmentLineLocked()
|
||||||
titles := append([]string(nil), c.recentTitles...)
|
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)
|
msg, err := nudge.Nudge(ctx, commitment, titles)
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if gen != c.sessionGen || c.runtimeState != domain.RuntimeActive {
|
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
|
||||||
c.mu.Unlock()
|
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 {
|
if err != nil {
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|||||||
@@ -693,11 +693,26 @@ func TestNilNudgerStaysSilentOnTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNudgeSurvivesInterleavedDriftJudgment(t *testing.T) {
|
func TestNudgeClearedOnLeavingAllowedApp(t *testing.T) {
|
||||||
c, _ := newTestController(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.SetNudge(fn)
|
||||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
|
||||||
startActive(t, c, []string{"code"})
|
startActive(t, c, []string{"code"})
|
||||||
c.RecordWindow(obs("code", "a.go"))
|
c.RecordWindow(obs("code", "a.go"))
|
||||||
c.RecordWindow(obs("code", "b.go")) // nudge launches, blocks on gate
|
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 {
|
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||||
time.Sleep(5 * time.Millisecond)
|
time.Sleep(5 * time.Millisecond)
|
||||||
}
|
}
|
||||||
// An interleaved drift judgment bumps driftGen; the nudge must NOT be
|
c.RecordWindow(obs("firefox", "YouTube")) // ends the on-task stretch -> epoch++
|
||||||
// discarded by it — it is guarded by session identity, not driftGen.
|
close(fn.gate) // in-flight nudge returns now
|
||||||
c.RecordWindow(obs("firefox", "YouTube"))
|
c.RecordWindow(obs("code", "c.go")) // back on task (debounced; no new nudge)
|
||||||
close(fn.gate) // nudge returns within the same session
|
time.Sleep(50 * time.Millisecond)
|
||||||
waitNudge(t, c, "kept")
|
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) {
|
func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user