Wire debounced cached async drift judgment into RecordWindow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -181,6 +181,10 @@ func New(snapshotPath string) (*Controller, error) {
|
|||||||
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
||||||
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
||||||
c.allowedClasses = s.AllowedWindowClasses
|
c.allowedClasses = s.AllowedWindowClasses
|
||||||
|
// Drift state is not persisted: recompute fresh after restart to avoid
|
||||||
|
// stale interrupts. This also initializes judgedClasses (the pipeline
|
||||||
|
// writes to it) so a restored Active session never panics on a nil map.
|
||||||
|
c.resetDriftLocked()
|
||||||
c.replayStats(s.SessionID)
|
c.replayStats(s.SessionID)
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
@@ -590,8 +594,99 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|||||||
now := c.clock()
|
now := c.clock()
|
||||||
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
||||||
c.applyEvent(now, snap)
|
c.applyEvent(now, snap)
|
||||||
|
launch := c.evaluateDriftLocked(now, snap)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if launch != nil {
|
||||||
|
go launch()
|
||||||
|
}
|
||||||
|
c.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
||||||
|
// and updates synchronous drift state. When an async judgment is warranted it
|
||||||
|
// returns the judging closure; the caller runs it in a goroutine after
|
||||||
|
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
|
||||||
|
// already verified the runtime is Active.
|
||||||
|
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.
|
||||||
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||||
|
if evidence.MatchesAllowed(ac, class, title) {
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.driftReason = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Per-class cache.
|
||||||
|
if v, ok := c.judgedClasses[class]; ok {
|
||||||
|
c.applyVerdictLocked(v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No judge wired: never block; leave idle.
|
||||||
|
if c.judge == nil {
|
||||||
|
c.driftStatus = driftIdle
|
||||||
|
c.driftReason = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Debounce: at most one judgment per driftDebounce window.
|
||||||
|
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prevStatus, prevReason := c.driftStatus, c.driftReason
|
||||||
|
c.driftGen++
|
||||||
|
gen := c.driftGen
|
||||||
|
c.lastJudgedAt = now
|
||||||
|
c.driftStatus = driftPending
|
||||||
|
c.driftReason = ""
|
||||||
|
judge := c.judge
|
||||||
|
commitment := ""
|
||||||
|
if c.commitment != nil {
|
||||||
|
commitment = c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
||||||
|
defer cancel()
|
||||||
|
v, err := judge.JudgeDrift(ctx, commitment, class, title)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return // stale: a newer judgment started or the session ended
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Degrade: never fabricate drift. Revert the optimistic pending.
|
||||||
|
if c.driftStatus == driftPending {
|
||||||
|
c.driftStatus = prevStatus
|
||||||
|
c.driftReason = prevReason
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
log.Printf("session: drift judge failed: %v", err)
|
||||||
|
c.notify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.judgedClasses[class] = v
|
||||||
|
if c.stats != nil && c.stats.Current.Class == class {
|
||||||
|
c.applyVerdictLocked(v)
|
||||||
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.notify()
|
c.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
||||||
|
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
|
||||||
|
if v.OnTask {
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.driftReason = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.driftStatus = driftDrifting
|
||||||
|
c.driftReason = v.Reason
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyEvent advances stats by one observation: it credits the prior segment to
|
// applyEvent advances stats by one observation: it credits the prior segment to
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -386,6 +387,155 @@ func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeJudge struct {
|
||||||
|
verdict ai.Verdict
|
||||||
|
err error
|
||||||
|
gate chan struct{}
|
||||||
|
calls int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||||
|
atomic.AddInt32(&f.calls, 1)
|
||||||
|
if f.gate != nil {
|
||||||
|
<-f.gate
|
||||||
|
}
|
||||||
|
return f.verdict, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitDriftStatus(t *testing.T, c *Controller, want string) State {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st := c.State()
|
||||||
|
if st.Drift != nil && st.Drift.Status == want {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("drift status never reached %q (last: %+v)", want, c.State().Drift)
|
||||||
|
return State{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startActive(t *testing.T, c *Controller, allowed []string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func obs(class, title string) evidence.WindowSnapshot {
|
||||||
|
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalMatchIsOnTaskWithoutJudge(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("Code", "main.go"))
|
||||||
|
st := c.State()
|
||||||
|
if st.Drift == nil || st.Drift.Status != "ontask" {
|
||||||
|
t.Fatalf("local match should be on-task, got %+v", st.Drift)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&fj.calls) != 0 {
|
||||||
|
t.Fatalf("judge must not be called on a local match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "YouTube is off-task"}})
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
st := waitDriftStatus(t, c, "drifting")
|
||||||
|
if st.Drift.Reason != "YouTube is off-task" {
|
||||||
|
t.Fatalf("reason not surfaced: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
now := time.Now()
|
||||||
|
c.SetClock(func() time.Time { return now })
|
||||||
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
waitDriftStatus(t, c, "drifting")
|
||||||
|
// Advance past the debounce window so a second judge call would be allowed:
|
||||||
|
// this isolates the per-class cache as the only reason the judge is skipped.
|
||||||
|
now = now.Add(2 * driftDebounce)
|
||||||
|
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
||||||
|
t.Fatalf("cached class should not re-judge, calls=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebounceLimitsJudgeCalls(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
now := time.Now()
|
||||||
|
c.SetClock(func() time.Time { return now })
|
||||||
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "a")) // launches one (blocked on gate)
|
||||||
|
c.RecordWindow(obs("chrome", "b")) // within debounce window -> suppressed
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
||||||
|
t.Fatalf("debounce should allow one judge call, calls=%d", got)
|
||||||
|
}
|
||||||
|
close(fj.gate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleJudgmentDiscardedAfterEnd(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube")) // launches, blocks on gate
|
||||||
|
waitDriftStatus(t, c, "pending")
|
||||||
|
_ = c.Complete()
|
||||||
|
_ = c.End()
|
||||||
|
close(fj.gate) // release stale goroutine; must be discarded
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if c.State().RuntimeState != domain.RuntimeLocked {
|
||||||
|
t.Fatalf("should be Locked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNilJudgeLeavesUnmatchedIdle(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
st := c.State()
|
||||||
|
if st.Drift == nil || st.Drift.Status != "idle" {
|
||||||
|
t.Fatalf("nil judge should leave unmatched idle, got %+v", st.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
_ = first.EnterPlanning()
|
||||||
|
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
|
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map
|
||||||
|
waitDriftStatus(t, second, "drifting")
|
||||||
|
}
|
||||||
|
|
||||||
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
|
|||||||
Reference in New Issue
Block a user