Classifies every running child as idle/working/thinking/permission/error using one of three pluggable strategies (output_activity, osc_title_stability, osc_title_status) plus optional regex promoters applied to the tail of recent output. State and last-match reason are exposed via MCP on ProcessInfo and get_process_status. Per-preset configuration lives on a new preset.IdleDetection block with bundled defaults for the first-party claude/codex/opencode presets. OSC title plumbing is exposed as Emulator.Title(), polled from the session pump after each emulator write so title-change activity feeds into the classifier without an extra cgo callback. The MCP timer surface expands to match Solo: timer_set, timer_fire_when_idle_any/all, timer_cancel, timer_pause, timer_resume, timer_list. timer_wait is now a thin wrapper that shares the same manager so it shows up in timer_list while pending. Timer bodies are delivered to the owner process through the existing InjectAsOrchestrator path. Top-level (non-agent) callers can attach timers to a specific process via owner_process_id; omitting it grants universal cancel/pause/resume/list privileges. The sidebar gains a state glyph per process row and appends a nearest-timer indicator when one is pending or paused. Tests: idle_test.go covers the classify() pure function across the three strategies and regex promotion; timers_test.go covers the manager. Harness scenarios cover output_activity, osc_title_stability, osc_title_status, and regex promotion, plus timer_set delivery, cancel, pause/resume, idle_any-on-transition, idle_all-pending, and idle_all-already-satisfied. A new wait_until_mcp harness step type polls an MCP method until an assertion holds.
224 lines
5.9 KiB
Go
224 lines
5.9 KiB
Go
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 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")
|
|
}
|
|
}
|