package app import ( "sync" "testing" "time" ) // recorderFire collects timer firings without touching a PTY. Lets the // timer manager run end-to-end logic in unit tests. type recorderFire struct { mu sync.Mutex fires []recordedFire } type recordedFire struct { OwnerID string Body string Label string } func (r *recorderFire) fn(owner *Child, body, label string) { r.mu.Lock() defer r.mu.Unlock() id := "" if owner != nil { id = owner.ID } r.fires = append(r.fires, recordedFire{OwnerID: id, Body: body, Label: label}) } func (r *recorderFire) snapshot() []recordedFire { r.mu.Lock() defer r.mu.Unlock() out := make([]recordedFire, len(r.fires)) copy(out, r.fires) return out } // fakeChild constructs a Child shell suitable for timer-manager tests. // Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is // never reached. func fakeChild(id string) *Child { c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "") running := StatusRunning c.status.Store(&running) return c } // addChild bypasses Spawn (no PTY needed) so the manager can find the // child by id and read its IdleState. func addChild(s *Session, c *Child) { s.mu.Lock() s.children[c.ID] = c s.order = append(s.order, c.ID) s.mu.Unlock() } func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) { t.Helper() sess := NewSession(t.TempDir(), "test") mgr := newTimerManager(sess) rec := &recorderFire{} mgr.fireFn = rec.fn return sess, mgr, rec } func waitTimerChange(t *testing.T, mgr *timerManager) { t.Helper() select { case <-mgr.changeEvents(): case <-time.After(time.Second): t.Fatal("timed out waiting for timer change signal") } } func TestNextTimerSidebarLabelChange(t *testing.T) { tests := []struct { name string d time.Duration want time.Duration }{ {name: "minutes", d: 2*time.Minute + 10*time.Second, want: 10 * time.Second}, {name: "minute_to_seconds", d: time.Minute + 500*time.Millisecond, want: 500 * time.Millisecond}, {name: "seconds", d: 59*time.Second + 500*time.Millisecond, want: 500 * time.Millisecond}, {name: "subsecond", d: 500 * time.Millisecond, want: timerSidebarSubsecondRefresh}, {name: "nearly_done", d: 30 * time.Millisecond, want: 30 * time.Millisecond}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := nextTimerSidebarLabelChange(tt.d); got != tt.want { t.Fatalf("nextTimerSidebarLabelChange(%s) = %s, want %s", tt.d, got, tt.want) } }) } } func TestTimerSidebarRefreshAfterUsesSoonestActiveBoundary(t *testing.T) { _, mgr, _ := newTestManager(t) now := time.Unix(123, 0) mgr.mu.Lock() mgr.timers["slow"] = &pendingTimer{ id: "slow", status: timerStatusPending, firesAt: now.Add(2*time.Minute + 10*time.Second), } mgr.timers["fast"] = &pendingTimer{ id: "fast", status: timerStatusPending, firesAt: now.Add(59*time.Second + 500*time.Millisecond), } mgr.timers["paused"] = &pendingTimer{ id: "paused", status: timerStatusPaused, firesAt: now.Add(100 * time.Millisecond), } mgr.mu.Unlock() got, ok := mgr.nextSidebarRefreshAfter(now) if !ok { t.Fatal("nextSidebarRefreshAfter did not find active timers") } if got != 500*time.Millisecond { t.Fatalf("nextSidebarRefreshAfter = %s, want 500ms", got) } } func TestTimerManagerSignalsChangesForSidebar(t *testing.T) { sess, mgr, _ := newTestManager(t) owner := fakeChild("p_owner") addChild(sess, owner) id, err := mgr.TimerSet("p_owner", "x", "", 60) if err != nil { t.Fatalf("TimerSet: %v", err) } waitTimerChange(t, mgr) if err := mgr.TimerPause("p_owner", id); err != nil { t.Fatalf("TimerPause: %v", err) } waitTimerChange(t, mgr) if err := mgr.TimerResume("p_owner", id); err != nil { t.Fatalf("TimerResume: %v", err) } waitTimerChange(t, mgr) if err := mgr.TimerCancel("p_owner", id); err != nil { t.Fatalf("TimerCancel: %v", err) } waitTimerChange(t, mgr) } func TestTimerSetDelivers(t *testing.T) { sess, mgr, rec := newTestManager(t) c := fakeChild("p_owner") addChild(sess, c) id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05) if err != nil { t.Fatalf("TimerSet: %v", err) } if id == "" { t.Fatal("empty timer id") } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if len(rec.snapshot()) > 0 { break } time.Sleep(10 * time.Millisecond) } got := rec.snapshot() if len(got) != 1 { t.Fatalf("got %d fires, want 1", len(got)) } if got[0].Body != "wake up" || got[0].OwnerID != "p_owner" { t.Fatalf("unexpected fire: %+v", got[0]) } } func TestTimerIdleAllAlreadySatisfied(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") b := fakeChild("p_b") addChild(sess, owner) addChild(sess, a) addChild(sess, b) idle := StateIdle a.idleState.Store(&idle) b.idleState.Store(&idle) resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAll: %v", err) } if resp.Status != "already_satisfied" { t.Fatalf("status: got %q want already_satisfied", resp.Status) } // fire is dispatched on a goroutine; wait briefly. time.Sleep(50 * time.Millisecond) got := rec.snapshot() if len(got) != 1 || got[0].Body != "all done" { t.Fatalf("fires: %+v", got) } } func TestTimerIdleAnyFiresOnTransition(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") addChild(sess, owner) addChild(sess, a) // p_a starts busy. working := StateWorking a.idleState.Store(&working) resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } if resp.Status != "pending" { t.Fatalf("status: got %q want pending", resp.Status) } // Flip a into idle and deliver the state-change event. idle := StateIdle a.idleState.Store(&idle) mgr.onChildStateChanged("p_a", StateIdle) got := rec.snapshot() if len(got) != 1 || got[0].Body != "one done" { t.Fatalf("fires: %+v", got) } } func TestTimerIdleAnyExcludesBaseline(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") addChild(sess, owner) addChild(sess, a) idle := StateIdle a.idleState.Store(&idle) resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } if resp.Status != "pending" { t.Fatalf("status: got %q want pending", resp.Status) } // Send a redundant idle transition for p_a; should NOT fire because // p_a was idle at registration. mgr.onChildStateChanged("p_a", StateIdle) if got := rec.snapshot(); len(got) != 0 { t.Fatalf("unexpected fires: %+v", got) } } func TestTimerCancelPauseResume(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") addChild(sess, owner) // Cancel before fire. id, _ := mgr.TimerSet("p_owner", "x", "", 0.2) if err := mgr.TimerCancel("p_owner", id); err != nil { t.Fatalf("Cancel: %v", err) } time.Sleep(300 * time.Millisecond) if got := rec.snapshot(); len(got) != 0 { t.Fatalf("cancel didn't stop fire: %+v", got) } // Pause then resume → fire after resume. id2, _ := mgr.TimerSet("p_owner", "y", "", 0.2) time.Sleep(50 * time.Millisecond) if err := mgr.TimerPause("p_owner", id2); err != nil { t.Fatalf("Pause: %v", err) } time.Sleep(300 * time.Millisecond) // would have fired by now if not paused if got := rec.snapshot(); len(got) != 0 { t.Fatalf("paused timer fired: %+v", got) } if err := mgr.TimerResume("p_owner", id2); err != nil { t.Fatalf("Resume: %v", err) } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if len(rec.snapshot()) > 0 { break } time.Sleep(20 * time.Millisecond) } if got := rec.snapshot(); len(got) != 1 || got[0].Body != "y" { t.Fatalf("resume fire: %+v", got) } } func TestTimerOwnershipEnforced(t *testing.T) { sess, mgr, _ := newTestManager(t) a := fakeChild("p_a") b := fakeChild("p_b") addChild(sess, a) addChild(sess, b) id, _ := mgr.TimerSet("p_a", "hi", "", 60) if err := mgr.TimerCancel("p_b", id); err == nil { t.Fatal("expected ownership error from foreign cancel") } if err := mgr.TimerPause("p_b", id); err == nil { t.Fatal("expected ownership error from foreign pause") } } // TestTimerResumeRechecksIdleAll covers the case where every watched // child becomes idle while an idle_all timer is paused. Without a resume // re-check, the timer would stay pending forever because the state // transitions happened during the pause window. func TestTimerResumeRechecksIdleAll(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") b := fakeChild("p_b") addChild(sess, owner) addChild(sess, a) addChild(sess, b) working := StateWorking a.idleState.Store(&working) b.idleState.Store(&working) resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAll: %v", err) } if resp.Status != "pending" { t.Fatalf("status: got %q want pending", resp.Status) } if err := mgr.TimerPause("p_owner", resp.ID); err != nil { t.Fatalf("Pause: %v", err) } // Both watched children become idle WHILE THE TIMER IS PAUSED, so // onChildStateChanged is not consulted for this timer. idle := StateIdle a.idleState.Store(&idle) b.idleState.Store(&idle) if err := mgr.TimerResume("p_owner", resp.ID); err != nil { t.Fatalf("Resume: %v", err) } got := rec.snapshot() if len(got) != 1 || got[0].Body != "all done" { t.Fatalf("expected fire on resume, got: %+v", got) } } // TestTimerResumeRechecksIdleAny covers the same missed-transition shape // for idle_any: a non-baseline watched child going idle during pause must // fire on resume. func TestTimerResumeRechecksIdleAny(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") addChild(sess, owner) addChild(sess, a) working := StateWorking a.idleState.Store(&working) resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } if resp.Status != "pending" { t.Fatalf("status: got %q want pending", resp.Status) } if err := mgr.TimerPause("p_owner", resp.ID); err != nil { t.Fatalf("Pause: %v", err) } idle := StateIdle a.idleState.Store(&idle) if err := mgr.TimerResume("p_owner", resp.ID); err != nil { t.Fatalf("Resume: %v", err) } got := rec.snapshot() if len(got) != 1 || got[0].Body != "one done" { t.Fatalf("expected fire on resume, got: %+v", got) } } // TestTimerResumeIdleAnyExcludesBaselineDuringPause guards against a // resume re-check firing for a watcher that was idle at registration // (and therefore part of the baseline) — only non-baseline transitions // should satisfy idle_any. func TestTimerResumeIdleAnyExcludesBaselineDuringPause(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") b := fakeChild("p_b") addChild(sess, owner) addChild(sess, a) addChild(sess, b) idle := StateIdle working := StateWorking a.idleState.Store(&idle) // baseline: already idle b.idleState.Store(&working) // not baseline resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } if err := mgr.TimerPause("p_owner", resp.ID); err != nil { t.Fatalf("Pause: %v", err) } // b stays working — only a is idle, and a was baseline. Resume // must not fire. if err := mgr.TimerResume("p_owner", resp.ID); err != nil { t.Fatalf("Resume: %v", err) } if got := rec.snapshot(); len(got) != 0 { t.Fatalf("unexpected fire on resume: %+v", got) } } // TestTimerRecordsRemovedOnFire ensures fired delay timers don't leak // in the timer registry — bodies and metadata must be released. func TestTimerRecordsRemovedOnFire(t *testing.T) { sess, mgr, rec := newTestManager(t) c := fakeChild("p_owner") addChild(sess, c) id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05) if err != nil { t.Fatalf("TimerSet: %v", err) } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if len(rec.snapshot()) > 0 { break } time.Sleep(10 * time.Millisecond) } if len(rec.snapshot()) != 1 { t.Fatalf("timer didn't fire") } mgr.mu.Lock() _, stillThere := mgr.timers[id] count := len(mgr.timers) mgr.mu.Unlock() if stillThere { t.Fatalf("fired timer %s was not removed from registry", id) } if count != 0 { t.Fatalf("timer registry not drained: %d entries", count) } } // TestTimerRecordsRemovedOnCancel ensures canceled timers are dropped // from the registry. func TestTimerRecordsRemovedOnCancel(t *testing.T) { sess, mgr, _ := newTestManager(t) c := fakeChild("p_owner") addChild(sess, c) id, err := mgr.TimerSet("p_owner", "x", "", 60) if err != nil { t.Fatalf("TimerSet: %v", err) } if err := mgr.TimerCancel("p_owner", id); err != nil { t.Fatalf("Cancel: %v", err) } mgr.mu.Lock() _, stillThere := mgr.timers[id] mgr.mu.Unlock() if stillThere { t.Fatalf("canceled timer %s was not removed from registry", id) } } // TestTimerRecordsRemovedOnIdleFire ensures idle_* timers are dropped // from the registry once they fire via onChildStateChanged. func TestTimerRecordsRemovedOnIdleFire(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") addChild(sess, owner) addChild(sess, a) working := StateWorking a.idleState.Store(&working) resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } idle := StateIdle a.idleState.Store(&idle) mgr.onChildStateChanged("p_a", StateIdle) if got := rec.snapshot(); len(got) != 1 { t.Fatalf("expected fire, got: %+v", got) } mgr.mu.Lock() _, stillThere := mgr.timers[resp.ID] mgr.mu.Unlock() if stillThere { t.Fatalf("fired idle timer %s was not removed from registry", resp.ID) } } // TestTimerCloseChildPrunesWatched covers the happy partial-prune // case: an idle_any timer watches two children, one is closed, the // timer stays pending and the remaining child can still satisfy it. func TestTimerCloseChildPrunesWatched(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") b := fakeChild("p_b") addChild(sess, owner) addChild(sess, a) addChild(sess, b) working := StateWorking a.idleState.Store(&working) b.idleState.Store(&working) resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } mgr.onChildClosed("p_a") mgr.mu.Lock() t1, ok := mgr.timers[resp.ID] if !ok { mgr.mu.Unlock() t.Fatalf("timer was removed but still has live watched") } watched := append([]string(nil), t1.watched...) mgr.mu.Unlock() if len(watched) != 1 || watched[0] != "p_b" { t.Fatalf("watched after close: %v, want [p_b]", watched) } if got := rec.snapshot(); len(got) != 0 { t.Fatalf("close synthesised a fire: %+v", got) } // p_b can still satisfy the timer. idle := StateIdle b.idleState.Store(&idle) mgr.onChildStateChanged("p_b", StateIdle) if got := rec.snapshot(); len(got) != 1 || got[0].Body != "one done" { t.Fatalf("post-prune fire: %+v", got) } } // TestTimerCloseLastWatchedCancels is the regression for the // reported stale-fire symptom: the only watched child is closed, // so the timer must be cancelled — no synthetic fire, and the // registry entry must be gone so a trailing classifier tick for the // removed child cannot re-deliver later. func TestTimerCloseLastWatchedCancels(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") addChild(sess, owner) addChild(sess, a) working := StateWorking a.idleState.Store(&working) resp, err := mgr.TimerFireWhenIdleAny("p_owner", "stale body", "", []string{"p_a"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } mgr.onChildClosed("p_a") mgr.mu.Lock() _, stillThere := mgr.timers[resp.ID] mgr.mu.Unlock() if stillThere { t.Fatalf("timer with no remaining watched should be removed") } if got := rec.snapshot(); len(got) != 0 { t.Fatalf("close synthesised a fire: %+v", got) } // Simulate the trailing classifier tick for the now-closed child — // must not fire. mgr.onChildStateChanged("p_a", StateIdle) if got := rec.snapshot(); len(got) != 0 { t.Fatalf("trailing state change re-fired: %+v", got) } } // TestTimerCloseChildIdleAllPartialPrune mirrors the idle_any // partial-prune for idle_all: pruning a watched child shrinks the // list; the remaining child going idle then satisfies the timer. func TestTimerCloseChildIdleAllPartialPrune(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") b := fakeChild("p_b") addChild(sess, owner) addChild(sess, a) addChild(sess, b) working := StateWorking a.idleState.Store(&working) b.idleState.Store(&working) resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAll: %v", err) } if resp.Status != "pending" { t.Fatalf("status: got %q want pending", resp.Status) } mgr.onChildClosed("p_a") idle := StateIdle b.idleState.Store(&idle) mgr.onChildStateChanged("p_b", StateIdle) if got := rec.snapshot(); len(got) != 1 || got[0].Body != "all done" { t.Fatalf("idle_all after partial prune: %+v", got) } } // TestTimerCloseOwnerCancelsDelay ensures a delay timer is dropped // when its owner is closed: no delivery, registry empty, the // underlying time.Timer is stopped. func TestTimerCloseOwnerCancelsDelay(t *testing.T) { sess, mgr, rec := newTestManager(t) c := fakeChild("p_owner") addChild(sess, c) id, err := mgr.TimerSet("p_owner", "x", "", 0.1) if err != nil { t.Fatalf("TimerSet: %v", err) } mgr.onChildClosed("p_owner") mgr.mu.Lock() _, stillThere := mgr.timers[id] mgr.mu.Unlock() if stillThere { t.Fatalf("delay timer was not removed when owner closed") } time.Sleep(200 * time.Millisecond) // past the original firesAt if got := rec.snapshot(); len(got) != 0 { t.Fatalf("delay timer fired after owner close: %+v", got) } } // TestTimerCloseWatchedSubAgent is the exact shape of the reported // stale-fire bug: orchestrator registers a watcher on a sub-agent, // the sub-agent is closed, and the orchestrator must receive // nothing (no stale body delivered after close_process). func TestTimerCloseWatchedSubAgent(t *testing.T) { sess, mgr, rec := newTestManager(t) parent := fakeChild("p_owner") sub := fakeChild("p_sub") addChild(sess, parent) addChild(sess, sub) working := StateWorking sub.idleState.Store(&working) if _, err := mgr.TimerFireWhenIdleAny( "p_owner", "codex-review-591 finished. Read your own pane …", "", []string{"p_sub"}, 0, ); err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } mgr.onChildClosed("p_sub") // Trailing classifier emission for the closed sub-agent must // not deliver anything to the parent. mgr.onChildStateChanged("p_sub", StateIdle) if got := rec.snapshot(); len(got) != 0 { t.Fatalf("stale fire delivered to parent after sub-agent close: %+v", got) } }