Add idle-state classifier and Solo-parity timer tools
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.
This commit is contained in:
223
internal/app/timers_test.go
Normal file
223
internal/app/timers_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user