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:
96
internal/app/classifier.go
Normal file
96
internal/app/classifier.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// classifierTickInterval is how often the per-session classifier wakes
|
||||
// up to re-evaluate every child's state. 250ms is fast enough that
|
||||
// the sidebar badge looks live, slow enough that the cost is invisible
|
||||
// even with dozens of children.
|
||||
const classifierTickInterval = 250 * time.Millisecond
|
||||
|
||||
// classifierTailBytes is the size of the ring-buffer tail the
|
||||
// classifier scans for promoter regexes. Big enough to catch a multi-
|
||||
// line "Approve?" prompt, small enough that we don't pay for a full
|
||||
// 1 MiB regex scan every tick.
|
||||
const classifierTailBytes = 4096
|
||||
|
||||
// runClassifier loops over every live child every classifierTickInterval
|
||||
// and updates IdleState when it changes. It runs until ctx is cancelled
|
||||
// (the host shutdown path cancels). One goroutine per Session is plenty
|
||||
// — the work is cheap (atomic loads + ~4 KiB regex scan per child).
|
||||
func (s *Session) runClassifier(ctx context.Context) {
|
||||
ticker := time.NewTicker(classifierTickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.classifyAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) classifyAll() {
|
||||
for _, c := range s.Children() {
|
||||
s.classifyOne(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) classifyOne(c *Child) {
|
||||
st := c.Status()
|
||||
exited := st == StatusExited || st == StatusErrored
|
||||
exitNonZero := false
|
||||
if exited {
|
||||
exitNonZero = c.ExitCode() != 0
|
||||
}
|
||||
idleMS := c.IdleMS()
|
||||
titleIdleMS := c.TitleIdleMS()
|
||||
title := c.Title()
|
||||
tail := c.tailBytes(classifierTailBytes)
|
||||
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail)
|
||||
if c.setIdleState(state, reason) {
|
||||
s.emitStateChanged(c.ID, state)
|
||||
}
|
||||
}
|
||||
|
||||
// tailBytes returns up to n bytes from the end of the ring buffer.
|
||||
// Safe to call from the classifier goroutine while pumpChild writes
|
||||
// from another goroutine — both serialise on ringMu.
|
||||
func (c *Child) tailBytes(n int) []byte {
|
||||
c.ringMu.Lock()
|
||||
defer c.ringMu.Unlock()
|
||||
have := int64(ringCap)
|
||||
if !c.ringFull {
|
||||
have = c.ringWrites
|
||||
}
|
||||
if have == 0 {
|
||||
return nil
|
||||
}
|
||||
want := int64(n)
|
||||
if want > have {
|
||||
want = have
|
||||
}
|
||||
out := make([]byte, want)
|
||||
// The ring layout matches StreamRead: when not full, byte k lives
|
||||
// at index k; when full, the oldest byte sits at ringPos and the
|
||||
// newest at (ringPos-1) mod ringCap.
|
||||
if !c.ringFull {
|
||||
copy(out, c.ring[c.ringWrites-want:c.ringWrites])
|
||||
return out
|
||||
}
|
||||
// Tail starts `want` bytes back from the write head.
|
||||
start := (c.ringPos - int(want) + ringCap) % ringCap
|
||||
first := ringCap - start
|
||||
if first > int(want) {
|
||||
first = int(want)
|
||||
}
|
||||
copy(out, c.ring[start:start+first])
|
||||
if first < int(want) {
|
||||
copy(out[first:], c.ring[:int(want)-first])
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user