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:
488
internal/app/timers.go
Normal file
488
internal/app/timers.go
Normal file
@@ -0,0 +1,488 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/mcp"
|
||||
)
|
||||
|
||||
// pendingTimerKind picks the firing rule.
|
||||
type pendingTimerKind string
|
||||
|
||||
const (
|
||||
timerKindDelay pendingTimerKind = "delay"
|
||||
timerKindIdleAny pendingTimerKind = "idle_any"
|
||||
timerKindIdleAll pendingTimerKind = "idle_all"
|
||||
)
|
||||
|
||||
const (
|
||||
timerStatusPending = "pending"
|
||||
timerStatusPaused = "paused"
|
||||
timerStatusFired = "fired"
|
||||
timerStatusCanceled = "canceled"
|
||||
)
|
||||
|
||||
// pendingTimer is one live timer tracked by the manager. The body is
|
||||
// delivered verbatim to the owning child's PTY as a fresh user turn
|
||||
// when the timer fires.
|
||||
//
|
||||
// Locking: every field is protected by timerManager.mu. The runtime
|
||||
// time.Timer (rt) is started outside the lock so the firing goroutine
|
||||
// can take the lock without deadlocking.
|
||||
type pendingTimer struct {
|
||||
id string
|
||||
label string
|
||||
body string
|
||||
ownerID string
|
||||
kind pendingTimerKind
|
||||
status string
|
||||
|
||||
watched []string
|
||||
idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction)
|
||||
|
||||
firesAt time.Time
|
||||
pausedRemaining time.Duration
|
||||
pausedWasMaxWait bool // for idle_*: true if the active timer was max-wait, not delay
|
||||
|
||||
rt *time.Timer // delay timer or idle_* max-wait fallback
|
||||
}
|
||||
|
||||
// timerManager owns the pending-timer registry. Mutating operations
|
||||
// (set, cancel, pause, resume) all serialise through mu; fire callbacks
|
||||
// from the runtime timer also take mu to safely transition state.
|
||||
type timerManager struct {
|
||||
sess *Session
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
timers map[string]*pendingTimer
|
||||
|
||||
// fireFn is the callback used to deliver the body to the owning
|
||||
// process. Decoupled so tests can substitute a recorder. Defaults
|
||||
// to caller.InjectAsOrchestrator + "\r".
|
||||
fireFn func(owner *Child, body, label string)
|
||||
}
|
||||
|
||||
func newTimerManager(sess *Session) *timerManager {
|
||||
m := &timerManager{
|
||||
sess: sess,
|
||||
timers: make(map[string]*pendingTimer),
|
||||
}
|
||||
m.fireFn = defaultFireFn
|
||||
return m
|
||||
}
|
||||
|
||||
func defaultFireFn(owner *Child, body, label string) {
|
||||
if owner == nil || !owner.IsLive() {
|
||||
return
|
||||
}
|
||||
// Solo delivers body verbatim. patterm's PTY-injection path expects
|
||||
// a trailing CR so the line submits in TUI agents (Claude/Codex/
|
||||
// OpenCode all paste-detect). A bare body without CR sits in the
|
||||
// input buffer; that's almost never what the caller wants.
|
||||
if body == "" {
|
||||
body = fmt.Sprintf("[system] Your timer [%s] has completed.", label)
|
||||
}
|
||||
_ = owner.InjectAsOrchestrator([]byte(body + "\r"))
|
||||
}
|
||||
|
||||
func (m *timerManager) mintID() string {
|
||||
m.nextID++
|
||||
return fmt.Sprintf("t%d", m.nextID)
|
||||
}
|
||||
|
||||
// TimerSet schedules a delay timer. Returns immediately; the body is
|
||||
// delivered to the owning child when the timer fires.
|
||||
func (m *timerManager) TimerSet(ownerID string, body, label string, seconds float64) (string, error) {
|
||||
owner := m.sess.FindChild(ownerID)
|
||||
if owner == nil {
|
||||
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID)
|
||||
}
|
||||
if seconds < 0 {
|
||||
return "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer_set: seconds must be ≥ 0")
|
||||
}
|
||||
d := time.Duration(seconds * float64(time.Second))
|
||||
m.mu.Lock()
|
||||
id := m.mintID()
|
||||
if label == "" {
|
||||
label = id
|
||||
}
|
||||
t := &pendingTimer{
|
||||
id: id,
|
||||
label: label,
|
||||
body: body,
|
||||
ownerID: ownerID,
|
||||
kind: timerKindDelay,
|
||||
status: timerStatusPending,
|
||||
firesAt: time.Now().Add(d),
|
||||
}
|
||||
m.timers[id] = t
|
||||
m.mu.Unlock()
|
||||
t.rt = time.AfterFunc(d, func() { m.fireDelay(id) })
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (m *timerManager) fireDelay(id string) {
|
||||
m.mu.Lock()
|
||||
t, ok := m.timers[id]
|
||||
if !ok || t.status != timerStatusPending {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.status = timerStatusFired
|
||||
owner := m.sess.FindChild(t.ownerID)
|
||||
body, label := t.body, t.label
|
||||
m.mu.Unlock()
|
||||
m.fireFn(owner, body, label)
|
||||
}
|
||||
|
||||
// TimerFireWhenIdleAny schedules an idle-any timer. Children already
|
||||
// idle at registration are excluded from satisfaction — only a
|
||||
// transition into idle by a still-active watched child fires the
|
||||
// timer. Max-wait, when positive, acts as a fallback fire deadline.
|
||||
func (m *timerManager) TimerFireWhenIdleAny(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
|
||||
return m.registerIdleTimer(timerKindIdleAny, ownerID, body, label, watched, maxWait)
|
||||
}
|
||||
|
||||
// TimerFireWhenIdleAll schedules an idle-all timer. Already-idle
|
||||
// children count as satisfied; if every watched child is already idle
|
||||
// at registration time the response is "already_satisfied" with no
|
||||
// timer created.
|
||||
func (m *timerManager) TimerFireWhenIdleAll(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
|
||||
return m.registerIdleTimer(timerKindIdleAll, ownerID, body, label, watched, maxWait)
|
||||
}
|
||||
|
||||
func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
|
||||
if m.sess.FindChild(ownerID) == nil {
|
||||
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID)
|
||||
}
|
||||
if len(watched) == 0 {
|
||||
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "watched must contain at least one process_id")
|
||||
}
|
||||
if maxWait < 0 {
|
||||
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "max_wait_seconds must be ≥ 0")
|
||||
}
|
||||
// Validate watched ids and compute the idle baseline up front.
|
||||
already := make([]string, 0)
|
||||
waiting := make([]string, 0)
|
||||
baseline := make(map[string]bool, len(watched))
|
||||
for _, id := range watched {
|
||||
c := m.sess.FindChild(id)
|
||||
if c == nil {
|
||||
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q in watched", id)
|
||||
}
|
||||
if isIdleState(c.IdleState()) {
|
||||
already = append(already, id)
|
||||
baseline[id] = true
|
||||
} else {
|
||||
waiting = append(waiting, id)
|
||||
}
|
||||
}
|
||||
resp := mcp.TimerFireWhenIdleResponse{AlreadyIdle: already, WaitingOn: waiting}
|
||||
|
||||
// idle_all: if all watched are already idle, satisfy synchronously
|
||||
// — Solo semantics; no pending timer is created.
|
||||
if kind == timerKindIdleAll && len(waiting) == 0 {
|
||||
resp.Status = "already_satisfied"
|
||||
owner := m.sess.FindChild(ownerID)
|
||||
go m.fireFn(owner, body, label)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
id := m.mintID()
|
||||
if label == "" {
|
||||
label = id
|
||||
}
|
||||
t := &pendingTimer{
|
||||
id: id,
|
||||
label: label,
|
||||
body: body,
|
||||
ownerID: ownerID,
|
||||
kind: kind,
|
||||
status: timerStatusPending,
|
||||
watched: append([]string(nil), watched...),
|
||||
idleBaseline: baseline,
|
||||
}
|
||||
if maxWait > 0 {
|
||||
d := time.Duration(maxWait * float64(time.Second))
|
||||
t.firesAt = time.Now().Add(d)
|
||||
t.rt = time.AfterFunc(d, func() { m.fireIdleMaxWait(id) })
|
||||
}
|
||||
m.timers[id] = t
|
||||
m.mu.Unlock()
|
||||
resp.ID = id
|
||||
resp.Status = "pending"
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *timerManager) fireIdleMaxWait(id string) {
|
||||
m.mu.Lock()
|
||||
t, ok := m.timers[id]
|
||||
if !ok || t.status != timerStatusPending {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.status = timerStatusFired
|
||||
owner := m.sess.FindChild(t.ownerID)
|
||||
body, label := t.body, t.label
|
||||
m.mu.Unlock()
|
||||
m.fireFn(owner, body, label)
|
||||
}
|
||||
|
||||
// onChildStateChanged evaluates every pending idle_any / idle_all
|
||||
// timer whenever any child's IdleState flips. Cheap — there are few
|
||||
// pending timers and the per-tick check is just a map lookup + a slice
|
||||
// scan.
|
||||
func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
||||
if !isIdleState(state) {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
type firing struct {
|
||||
owner *Child
|
||||
body string
|
||||
label string
|
||||
}
|
||||
var fires []firing
|
||||
for _, t := range m.timers {
|
||||
if t.status != timerStatusPending {
|
||||
continue
|
||||
}
|
||||
if !contains(t.watched, childID) {
|
||||
continue
|
||||
}
|
||||
switch t.kind {
|
||||
case timerKindIdleAny:
|
||||
if t.idleBaseline[childID] {
|
||||
continue // already idle at registration; excluded
|
||||
}
|
||||
t.status = timerStatusFired
|
||||
if t.rt != nil {
|
||||
t.rt.Stop()
|
||||
}
|
||||
fires = append(fires, firing{
|
||||
owner: m.sess.FindChild(t.ownerID),
|
||||
body: t.body,
|
||||
label: t.label,
|
||||
})
|
||||
case timerKindIdleAll:
|
||||
if m.allWatchedIdleLocked(t) {
|
||||
t.status = timerStatusFired
|
||||
if t.rt != nil {
|
||||
t.rt.Stop()
|
||||
}
|
||||
fires = append(fires, firing{
|
||||
owner: m.sess.FindChild(t.ownerID),
|
||||
body: t.body,
|
||||
label: t.label,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, f := range fires {
|
||||
m.fireFn(f.owner, f.body, f.label)
|
||||
}
|
||||
}
|
||||
|
||||
// allWatchedIdleLocked reports whether every watched child is now
|
||||
// idle. Called with m.mu held — uses live Child.IdleState() under the
|
||||
// child's own atomic, not under m.mu.
|
||||
func (m *timerManager) allWatchedIdleLocked(t *pendingTimer) bool {
|
||||
for _, id := range t.watched {
|
||||
c := m.sess.FindChild(id)
|
||||
if c == nil {
|
||||
continue // disappeared; treat as satisfied so we don't hang
|
||||
}
|
||||
if !isIdleState(c.IdleState()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TimerCancel removes a pending or paused timer owned by ownerID.
|
||||
func (m *timerManager) TimerCancel(ownerID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.timers[id]
|
||||
if !ok {
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
||||
}
|
||||
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
||||
// MCP client); allow it to manage every timer in the session.
|
||||
// Otherwise the caller's own id must match the timer's owner.
|
||||
if ownerID != "" && t.ownerID != ownerID {
|
||||
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
||||
}
|
||||
if t.status == timerStatusFired || t.status == timerStatusCanceled {
|
||||
// Cancelling a fired/cancelled timer is idempotent.
|
||||
return nil
|
||||
}
|
||||
if t.rt != nil {
|
||||
t.rt.Stop()
|
||||
t.rt = nil
|
||||
}
|
||||
t.status = timerStatusCanceled
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimerPause stops the delay clock (or detaches the idle watch) but
|
||||
// keeps the timer in the registry.
|
||||
func (m *timerManager) TimerPause(ownerID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.timers[id]
|
||||
if !ok {
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
||||
}
|
||||
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
||||
// MCP client); allow it to manage every timer in the session.
|
||||
// Otherwise the caller's own id must match the timer's owner.
|
||||
if ownerID != "" && t.ownerID != ownerID {
|
||||
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
||||
}
|
||||
if t.status != timerStatusPending {
|
||||
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
|
||||
}
|
||||
if t.rt != nil {
|
||||
t.pausedRemaining = time.Until(t.firesAt)
|
||||
if t.pausedRemaining < 0 {
|
||||
t.pausedRemaining = 0
|
||||
}
|
||||
t.rt.Stop()
|
||||
t.rt = nil
|
||||
// For idle_* timers, only the max-wait timer rides on rt — the
|
||||
// idle-evaluation path lives in onChildStateChanged. Mark the
|
||||
// pause so resume rearms the right thing.
|
||||
t.pausedWasMaxWait = t.kind != timerKindDelay
|
||||
}
|
||||
t.status = timerStatusPaused
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimerResume re-arms a paused timer. For delay timers the remaining
|
||||
// duration is restored; idle-* timers re-attach to the state-change
|
||||
// watch list, and any remaining max-wait clock resumes.
|
||||
func (m *timerManager) TimerResume(ownerID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.timers[id]
|
||||
if !ok {
|
||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
||||
}
|
||||
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
||||
// MCP client); allow it to manage every timer in the session.
|
||||
// Otherwise the caller's own id must match the timer's owner.
|
||||
if ownerID != "" && t.ownerID != ownerID {
|
||||
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
||||
}
|
||||
if t.status != timerStatusPaused {
|
||||
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id)
|
||||
}
|
||||
t.status = timerStatusPending
|
||||
if t.pausedRemaining > 0 {
|
||||
t.firesAt = time.Now().Add(t.pausedRemaining)
|
||||
switch t.kind {
|
||||
case timerKindDelay:
|
||||
localID := id
|
||||
t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireDelay(localID) })
|
||||
default:
|
||||
localID := id
|
||||
t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireIdleMaxWait(localID) })
|
||||
}
|
||||
t.pausedRemaining = 0
|
||||
t.pausedWasMaxWait = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimerList returns timers owned by ownerID, oldest-first. An empty
|
||||
// ownerID lists every active timer — the top-level orchestrator view.
|
||||
func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]mcp.TimerInfo, 0)
|
||||
for _, t := range m.timers {
|
||||
if ownerID != "" && t.ownerID != ownerID {
|
||||
continue
|
||||
}
|
||||
if t.status != timerStatusPending && t.status != timerStatusPaused {
|
||||
continue
|
||||
}
|
||||
info := mcp.TimerInfo{
|
||||
ID: t.id,
|
||||
Label: t.label,
|
||||
Body: t.body,
|
||||
Kind: string(t.kind),
|
||||
Status: t.status,
|
||||
OwnerID: t.ownerID,
|
||||
WatchedIDs: append([]string(nil), t.watched...),
|
||||
}
|
||||
if t.status == timerStatusPending && !t.firesAt.IsZero() {
|
||||
info.FiresAtUnixMS = t.firesAt.UnixMilli()
|
||||
}
|
||||
if t.status == timerStatusPaused && t.pausedRemaining > 0 {
|
||||
info.PausedRemainingMS = t.pausedRemaining.Milliseconds()
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// activeForChild returns the nearest pending or paused timer attached
|
||||
// to child id (either owned by it or watching it). Used by the sidebar
|
||||
// for the "⏱ 12s" indicator. nil when none.
|
||||
func (m *timerManager) activeForChild(id string) *mcp.TimerInfo {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var best *pendingTimer
|
||||
for _, t := range m.timers {
|
||||
if t.status != timerStatusPending && t.status != timerStatusPaused {
|
||||
continue
|
||||
}
|
||||
if t.ownerID != id && !contains(t.watched, id) {
|
||||
continue
|
||||
}
|
||||
if best == nil {
|
||||
best = t
|
||||
continue
|
||||
}
|
||||
if t.firesAt.Before(best.firesAt) && !t.firesAt.IsZero() {
|
||||
best = t
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil
|
||||
}
|
||||
info := mcp.TimerInfo{
|
||||
ID: best.id,
|
||||
Label: best.label,
|
||||
Kind: string(best.kind),
|
||||
Status: best.status,
|
||||
OwnerID: best.ownerID,
|
||||
}
|
||||
if best.status == timerStatusPending && !best.firesAt.IsZero() {
|
||||
info.FiresAtUnixMS = best.firesAt.UnixMilli()
|
||||
}
|
||||
if best.status == timerStatusPaused {
|
||||
info.PausedRemainingMS = best.pausedRemaining.Milliseconds()
|
||||
}
|
||||
return &info
|
||||
}
|
||||
|
||||
func isIdleState(s IdleState) bool {
|
||||
return s == StateIdle
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user