Wire debounced cached async drift judgment into RecordWindow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user