Wire debounced cached async drift judgment into RecordWindow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 17:14:20 -04:00
parent 2977b903c2
commit 4e7676560d
2 changed files with 245 additions and 0 deletions
+150
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"path/filepath"
"sync/atomic"
"testing"
"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) {
c, _ := newTestController(t)
_ = c.EnterPlanning()