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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user