Orchestrate semantic nudge on the on-task path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+107
-8
@@ -40,8 +40,12 @@ const (
|
||||
const (
|
||||
driftDebounce = 10 * time.Second
|
||||
driftTimeout = 30 * time.Second
|
||||
nudgeDebounce = 5 * time.Minute
|
||||
nudgeTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
const recentTitlesMax = 10
|
||||
|
||||
const (
|
||||
driftIdle = "idle"
|
||||
driftPending = "pending"
|
||||
@@ -93,8 +97,14 @@ type Controller struct {
|
||||
driftStatus string
|
||||
driftReason string
|
||||
driftGen int
|
||||
sessionGen int // bumped only at session boundaries; nudge staleness guard
|
||||
lastJudgedAt time.Time
|
||||
judgedClasses map[string]ai.Verdict
|
||||
|
||||
nudge ai.Nudger
|
||||
recentTitles []string // in-memory ring of recent distinct titles this session
|
||||
nudgeMessage string // current soft advisory ("" = none)
|
||||
lastNudgedAt time.Time
|
||||
}
|
||||
|
||||
// CommitmentView is the UI projection of the active commitment.
|
||||
@@ -114,10 +124,13 @@ type ProposalView struct {
|
||||
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||
}
|
||||
|
||||
// DriftView is the active-only drift projection.
|
||||
// DriftView is the active-only drift projection. Nudge is a separate axis from
|
||||
// Status: it is populated precisely when Status is "ontask" (semantic drift
|
||||
// inside an allowed app), so it cannot be folded into the status enum.
|
||||
type DriftView struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Nudge string `json:"nudge,omitempty"`
|
||||
}
|
||||
|
||||
type CoachView struct {
|
||||
@@ -272,7 +285,7 @@ func (c *Controller) stateLocked() State {
|
||||
if status == "" {
|
||||
status = driftIdle
|
||||
}
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason}
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
||||
}
|
||||
return st
|
||||
}
|
||||
@@ -347,13 +360,41 @@ func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
||||
// local matching and the drift judge are unaffected.
|
||||
func (c *Controller) SetNudge(n ai.Nudger) {
|
||||
c.mu.Lock()
|
||||
c.nudge = n
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// commitmentLineLocked renders the active commitment as a single line for AI
|
||||
// prompts, or "" if there is none. Caller holds mu.
|
||||
func (c *Controller) commitmentLineLocked() string {
|
||||
if c.commitment == nil {
|
||||
return ""
|
||||
}
|
||||
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
||||
}
|
||||
|
||||
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
|
||||
func (c *Controller) recentTitlesForTest() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.recentTitles...)
|
||||
}
|
||||
|
||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||
func (c *Controller) resetDriftLocked() {
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
c.driftGen++
|
||||
c.sessionGen++
|
||||
c.lastJudgedAt = time.Time{}
|
||||
c.judgedClasses = map[string]ai.Verdict{}
|
||||
c.recentTitles = nil
|
||||
c.nudgeMessage = ""
|
||||
c.lastNudgedAt = time.Time{}
|
||||
}
|
||||
|
||||
// OnTask appends the current window class to the session allowed-context, clears
|
||||
@@ -594,6 +635,7 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
||||
now := c.clock()
|
||||
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
||||
c.applyEvent(now, snap)
|
||||
c.recordTitleLocked(snap.Title)
|
||||
launch := c.evaluateDriftLocked(now, snap)
|
||||
c.mu.Unlock()
|
||||
if launch != nil {
|
||||
@@ -610,12 +652,14 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
||||
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
|
||||
class, title := snap.Class, snap.Title
|
||||
|
||||
// 1. Local match: authoritative on-task, no LLM.
|
||||
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
|
||||
// where the semantic nudge runs — the drift judge stays silent here, so the
|
||||
// two are mutually exclusive per observation.
|
||||
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||
if evidence.MatchesAllowed(ac, class, title) {
|
||||
c.driftStatus = driftOnTask
|
||||
c.driftReason = ""
|
||||
return nil
|
||||
return c.maybeNudgeLocked(now)
|
||||
}
|
||||
|
||||
// 2. Per-class cache.
|
||||
@@ -643,10 +687,7 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
|
||||
c.driftStatus = driftPending
|
||||
c.driftReason = ""
|
||||
judge := c.judge
|
||||
commitment := ""
|
||||
if c.commitment != nil {
|
||||
commitment = c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
||||
}
|
||||
commitment := c.commitmentLineLocked()
|
||||
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
||||
@@ -678,6 +719,64 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
|
||||
}
|
||||
}
|
||||
|
||||
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
|
||||
// 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.
|
||||
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
||||
if c.nudge == nil || len(c.recentTitles) < 2 {
|
||||
return nil
|
||||
}
|
||||
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
|
||||
return nil
|
||||
}
|
||||
c.lastNudgedAt = now
|
||||
gen := c.sessionGen
|
||||
nudge := c.nudge
|
||||
commitment := c.commitmentLineLocked()
|
||||
titles := append([]string(nil), c.recentTitles...)
|
||||
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
|
||||
defer cancel()
|
||||
msg, err := nudge.Nudge(ctx, commitment, titles)
|
||||
|
||||
c.mu.Lock()
|
||||
if gen != c.sessionGen || c.runtimeState != domain.RuntimeActive {
|
||||
c.mu.Unlock()
|
||||
return // stale: a new session started or the session ended
|
||||
}
|
||||
if err != nil {
|
||||
c.mu.Unlock()
|
||||
log.Printf("session: nudge failed: %v", err)
|
||||
return // never fabricate a concern; leave any prior message intact
|
||||
}
|
||||
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
||||
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
|
||||
func (c *Controller) recordTitleLocked(title string) {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
return
|
||||
}
|
||||
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
|
||||
return
|
||||
}
|
||||
c.recentTitles = append(c.recentTitles, t)
|
||||
if len(c.recentTitles) > recentTitlesMax {
|
||||
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
|
||||
}
|
||||
}
|
||||
|
||||
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
||||
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
|
||||
if v.OnTask {
|
||||
|
||||
@@ -3,6 +3,7 @@ package session
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -549,3 +550,208 @@ func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||
t.Fatal("coach view must be gone once Active")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeNudger is a controllable ai.Nudger for tests. An optional gate channel
|
||||
// blocks the call until closed, mirroring fakeJudge.
|
||||
type fakeNudger struct {
|
||||
msg string
|
||||
err error
|
||||
gate chan struct{}
|
||||
calls int32
|
||||
}
|
||||
|
||||
func (f *fakeNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||
atomic.AddInt32(&f.calls, 1)
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.msg, f.err
|
||||
}
|
||||
|
||||
// nudgerFunc adapts a function to ai.Nudger for tests needing per-call results.
|
||||
type nudgerFunc func(context.Context, string, []string) (string, error)
|
||||
|
||||
func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, error) {
|
||||
return f(ctx, c, t)
|
||||
}
|
||||
|
||||
func waitNudge(t *testing.T, c *Controller, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st := c.State()
|
||||
if st.Drift != nil && st.Drift.Nudge == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("nudge never reached %q (last: %+v)", want, c.State().Drift)
|
||||
}
|
||||
|
||||
func TestNudgeFiresOnOnTaskPath(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "drifted to an unrelated file"}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "auth.go")) // history 1 -> not yet eligible
|
||||
c.RecordWindow(obs("code", "styles.css")) // history 2 -> eligible, launches
|
||||
waitNudge(t, c, "drifted to an unrelated file")
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
||||
t.Fatalf("expected exactly one nudge call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "should not run"}
|
||||
c.SetNudge(fn)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
c.RecordWindow(obs("firefox", "Reddit"))
|
||||
waitDriftStatus(t, c, "drifting")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
||||
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeDebounced(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
now := time.Now()
|
||||
c.SetClock(func() time.Time { return now })
|
||||
fn := &fakeNudger{msg: "drift"}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // fires nudge #1
|
||||
waitNudge(t, c, "drift")
|
||||
c.RecordWindow(obs("code", "c.go")) // within debounce window -> suppressed
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
||||
t.Fatalf("debounce should allow one nudge, calls=%d", got)
|
||||
}
|
||||
now = now.Add(2 * nudgeDebounce)
|
||||
c.RecordWindow(obs("code", "d.go")) // debounce elapsed -> nudge #2
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) < 2 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 2 {
|
||||
t.Fatalf("expected second nudge after debounce, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeErrorDoesNotFabricate(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{err: errors.New("backend down")}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
|
||||
t.Fatalf("error must not set a nudge message: %+v", st.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeClearsWhenBackOnTrack(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
now := time.Now()
|
||||
c.SetClock(func() time.Time { return now })
|
||||
msgs := make(chan string, 2)
|
||||
msgs <- "drifted"
|
||||
msgs <- "" // back on track
|
||||
c.SetNudge(nudgerFunc(func(context.Context, string, []string) (string, error) {
|
||||
return <-msgs, nil
|
||||
}))
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
waitNudge(t, c, "drifted")
|
||||
now = now.Add(2 * nudgeDebounce)
|
||||
c.RecordWindow(obs("code", "c.go"))
|
||||
waitNudge(t, c, "") // an on-track result clears the prior advisory
|
||||
}
|
||||
|
||||
func TestNilNudgerStaysSilentOnTask(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
st := c.State()
|
||||
if st.Drift == nil || st.Drift.Status != "ontask" {
|
||||
t.Fatalf("expected ontask, got %+v", st.Drift)
|
||||
}
|
||||
if st.Drift.Nudge != "" {
|
||||
t.Fatalf("nil nudger must stay silent, got %q", st.Drift.Nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeSurvivesInterleavedDriftJudgment(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "kept", 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
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
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")
|
||||
}
|
||||
|
||||
func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "late", gate: make(chan struct{})}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // launches nudge, blocks on gate
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
_ = c.Complete()
|
||||
_ = c.End()
|
||||
// Start a fresh session; the previous session's in-flight nudge must not
|
||||
// leak into it.
|
||||
c.SetNudge(nil) // the new session launches no nudges of its own
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "c.go"))
|
||||
close(fn.gate) // release the stale goroutine from the first session
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
st := c.State()
|
||||
if st.RuntimeState != domain.RuntimeActive {
|
||||
t.Fatalf("expected Active second session, got %s", st.RuntimeState)
|
||||
}
|
||||
if st.Drift == nil || st.Drift.Nudge != "" {
|
||||
t.Fatalf("stale nudge must not leak into the new session: %+v", st.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTitlesRingCapsAtTen(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, []string{"code"})
|
||||
for i := 0; i < 15; i++ {
|
||||
c.RecordWindow(obs("code", fmt.Sprintf("file%d.go", i)))
|
||||
}
|
||||
titles := c.recentTitlesForTest()
|
||||
if len(titles) != 10 {
|
||||
t.Fatalf("ring should cap at 10, got %d", len(titles))
|
||||
}
|
||||
if titles[0] != "file5.go" {
|
||||
t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user