fix(ambient): re-check active mode after the judge call (coexistence TOCTOU)

This commit is contained in:
2026-06-05 21:39:46 -04:00
parent c963895599
commit 7507cc6d28
2 changed files with 46 additions and 0 deletions
+10
View File
@@ -236,6 +236,16 @@ func (s *Sentinel) evaluate(ctx context.Context) {
cancel()
s.mu.Lock()
// A mode may have activated during the (off-lock) brain call; re-check so a
// result computed while idle is not surfaced over an now-active mode.
if s.activeModeLocked() != "" {
changed := s.clearLocked()
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
s.lastEvalTitles = joined
if err != nil {
s.mu.Unlock()
+36
View File
@@ -228,6 +228,42 @@ func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) {
}
}
// flippingJudge simulates a harness mode activating DURING the brain call: it
// returns a drift verdict but flips the active-mode flag as a side effect, so by
// the time evaluate re-acquires the lock a mode is active.
type flippingJudge struct{ active *string }
func (f *flippingJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
*f.active = "focus"
return "deep in YouTube", nil
}
// A mode activating mid-evaluation must suppress the surface: no line, no toast,
// no memory record (coexistence rule holds across the unlock-for-I/O window).
func TestEvaluateModeActivatingDuringJudgeStaysSilent(t *testing.T) {
active := ""
n := &fakeNotifier{}
mem := &memoryFake{}
s := New(Deps{
AI: &flippingJudge{active: &active},
Notifier: n,
Memory: mem,
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
ActiveMode: func() string { return active },
}, time.Minute, modeNotify)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if s.Line() != "" {
t.Fatalf("mode active after judge must leave line empty, got %q", s.Line())
}
if n.calls != 0 {
t.Fatalf("mode active after judge must not toast, got %d", n.calls)
}
if len(mem.events) != 0 {
t.Fatalf("mode active after judge must not record memory, got %d", len(mem.events))
}
}
func TestSnoozeMutesAndClears(t *testing.T) {
j := &fakeJudge{out: []string{"drift", "drift"}}
active := ""