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:
2026-05-15 09:49:59 +01:00
parent 1af032472b
commit 2b9e1ed77c
31 changed files with 2318 additions and 38 deletions

View File

@@ -113,6 +113,11 @@ func Run(ctx context.Context, opts Options) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Per-session idle-detection classifier. One goroutine ticks every
// 250ms over every live child and updates IdleState. It stops when
// ctx is cancelled.
go sess.runClassifier(ctx)
st := &uiState{
sess: sess,
presets: presets,
@@ -120,6 +125,7 @@ func Run(ctx context.Context, opts Options) error {
pads: pads,
chromeWake: make(chan struct{}, 1),
trust: trustStore,
timers: host.timers,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
@@ -296,6 +302,7 @@ type uiState struct {
launcher *Launcher
pads *scratchpad.Store
trust *trust.Store
timers *timerManager
outMu sync.Mutex
@@ -610,6 +617,14 @@ func (st *uiState) OnChildSpawned(c *Child) {
st.drawStatusLine()
}
// OnChildStateChanged repaints the sidebar whenever a child's
// idle-state badge flips. Cheap — the badge is the only chrome that
// reflects state today, and drawSidebar bails when the cached frame
// hasn't changed.
func (st *uiState) OnChildStateChanged(string, IdleState) {
st.drawSidebar()
}
// OnChildExited drops focus and shows the empty state if it was the
// focused child.
func (st *uiState) OnChildExited(c *Child) {

View File

@@ -123,6 +123,19 @@ type Child struct {
portsMu sync.Mutex
ports []PortSighting
// Idle-detection state. idleState carries the classifier's current
// opinion (StateIdle / StateWorking / …). lastTitleNS is the wall
// time of the most recent OSC title change — separate from
// lastWriteNS so the osc_title_* strategies can ignore plain output
// churn. idleDetection is the compiled per-preset config, resolved
// once at spawn and immutable thereafter.
idleState atomic.Pointer[IdleState]
idleReason atomic.Pointer[string]
titleMu sync.RWMutex
title string
lastTitleNS atomic.Int64
idleDetection *resolvedIdleDetection
cleanupMu sync.Mutex
cleanupPaths []string
restarting atomic.Bool
@@ -330,6 +343,75 @@ func (c *Child) IdleMS() int64 {
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
// TitleIdleMS returns how many milliseconds since the OSC window title
// last changed. 0 means "no title set yet".
func (c *Child) TitleIdleMS() int64 {
last := c.lastTitleNS.Load()
if last == 0 {
return 0
}
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
// Title returns the most recent OSC 0/2 title.
func (c *Child) Title() string {
c.titleMu.RLock()
defer c.titleMu.RUnlock()
return c.title
}
// recordTitle updates the cached title and bumps lastTitleNS when it
// actually changes. Called from Session.pumpChild after each PTY chunk
// — cheap because most chunks don't carry an OSC sequence.
func (c *Child) recordTitle(newTitle string) {
c.titleMu.Lock()
if c.title == newTitle {
c.titleMu.Unlock()
return
}
c.title = newTitle
c.titleMu.Unlock()
c.lastTitleNS.Store(time.Now().UnixNano())
}
// IdleState returns the classifier's current opinion. Empty string
// (StateUnknown) means the classifier hasn't run yet for this child.
func (c *Child) IdleState() IdleState {
p := c.idleState.Load()
if p == nil {
return StateUnknown
}
return *p
}
// IdleReason returns the human-readable reason the classifier last
// recorded. Empty when no classification has happened yet.
func (c *Child) IdleReason() string {
p := c.idleReason.Load()
if p == nil {
return ""
}
return *p
}
// setIdleState updates idleState + idleReason. Returns true when the
// state actually changed (so callers can fan out a notification).
func (c *Child) setIdleState(s IdleState, reason string) bool {
prev := c.IdleState()
if prev == s {
return false
}
c.idleState.Store(&s)
c.idleReason.Store(&reason)
return true
}
// setIdleDetection installs the resolved per-preset idle-detection
// config. Called once at spawn; not safe to swap at runtime.
func (c *Child) setIdleDetection(r *resolvedIdleDetection) {
c.idleDetection = r
}
func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1)

View 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
}

View File

@@ -61,12 +61,11 @@ type toolHost struct {
prompter trustPrompter
scratch scratchpadSink
timersMu sync.Mutex
nextTimer int
timers *timerManager
}
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
return &toolHost{
h := &toolHost{
sess: sess,
pads: pads,
launcher: launcher,
@@ -76,6 +75,28 @@ func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, pres
defaultRow: rows,
startedAt: make(map[string]time.Time),
}
h.timers = newTimerManager(sess)
// Plug the timer manager into the session's state-change fan-out so
// idle-aware timers fire when watched children transition into idle.
// Tests can construct a host with a nil session for sizing checks —
// those never run timers, so the subscribe is skipped.
if sess != nil {
sess.Subscribe(timerListenerAdapter{m: h.timers})
}
return h
}
// timerListenerAdapter forwards OnChildStateChanged into the timer
// manager and ignores the other ChildEventListener methods. The
// session's listener API is by-interface, so we wrap the manager
// rather than make it implement the full surface.
type timerListenerAdapter struct{ m *timerManager }
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
func (a timerListenerAdapter) OnChildExited(*Child) {}
func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
a.m.onChildStateChanged(id, st)
}
func (h *toolHost) SetSize(cols, rows uint16) {
@@ -531,6 +552,7 @@ func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
default:
}
}
func (n *chunkNotifier) OnChildStateChanged(string, IdleState) {}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
@@ -725,27 +747,59 @@ func (h *toolHost) RequestHumanAttention(callerID, processID, reason string) err
return nil
}
// TimerWait is the legacy fire-and-forget delay timer. It now wraps
// TimerSet with an empty body — defaultFireFn substitutes the
// "[system] Your timer […] has completed." line so behaviour matches
// the original API. New callers should use timer_set with an explicit
// body.
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
caller := h.sess.FindChild(callerID)
if caller == nil {
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", callerID)
return h.timers.TimerSet(callerID, "", label, seconds)
}
func (h *toolHost) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
id, err := h.timers.TimerSet(owner, args.Body, args.Label, args.Seconds)
if err != nil {
return mcp.TimerHandle{}, err
}
h.timersMu.Lock()
h.nextTimer++
id := fmt.Sprintf("t%d", h.nextTimer)
h.timersMu.Unlock()
if label == "" {
label = id
return mcp.TimerHandle{ID: id}, nil
}
func (h *toolHost) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
return h.timers.TimerFireWhenIdleAny(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
}
func (h *toolHost) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
return h.timers.TimerFireWhenIdleAll(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
}
// resolveTimerOwner picks the owner process for a timer. Explicit
// owner_process_id wins; otherwise the caller's own id is used.
// Top-level MCP clients (no callerID) must provide owner_process_id
// explicitly.
func resolveTimerOwner(callerID, explicit string) string {
if explicit != "" {
return explicit
}
go func() {
time.Sleep(time.Duration(seconds * float64(time.Second)))
if !caller.IsLive() {
return
}
line := fmt.Sprintf("[system] Your timer [%s] has completed.\r", label)
_ = caller.InjectAsOrchestrator([]byte(line))
}()
return id, nil
return callerID
}
func (h *toolHost) TimerCancel(callerID, id string) error {
return h.timers.TimerCancel(callerID, id)
}
func (h *toolHost) TimerPause(callerID, id string) error {
return h.timers.TimerPause(callerID, id)
}
func (h *toolHost) TimerResume(callerID, id string) error {
return h.timers.TimerResume(callerID, id)
}
func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
return h.timers.TimerList(callerID), nil
}
// ───────────────────────────────────────────────────────────────────
@@ -816,6 +870,10 @@ func (h *toolHost) processInfoOf(c *Child) mcp.ProcessInfo {
t := h.trust.IsTrusted(c.PresetRef)
info.Trusted = &t
}
if s := c.IdleState(); s != StateUnknown {
info.IdleState = string(s)
info.IdleReason = c.IdleReason()
}
return info
}

225
internal/app/idle.go Normal file
View File

@@ -0,0 +1,225 @@
package app
import (
"regexp"
"github.com/hjbdev/patterm/internal/preset"
)
// IdleState is the classifier's opinion about what a child is doing.
// Inspired by Solo's five-state model. ERROR is a terminal state — set
// when a child exits non-zero or matches an error-promoter regex —
// while the other four reflect transient runtime state.
type IdleState string
const (
StateUnknown IdleState = ""
StateIdle IdleState = "idle"
StateWorking IdleState = "working"
StateThinking IdleState = "thinking"
StatePermission IdleState = "permission"
StateError IdleState = "error"
)
// IdleStrategy picks the primary signal used to decide idle vs working.
// Promoter regexes can override this on top.
type IdleStrategy string
const (
StrategyOutputActivity IdleStrategy = "output_activity"
StrategyOSCTitleStability IdleStrategy = "osc_title_stability"
StrategyOSCTitleStatus IdleStrategy = "osc_title_status"
)
// defaultIdleThresholdMS is used when a preset doesn't override it.
const defaultIdleThresholdMS = 2000
// resolvedIdleDetection is the compiled, runtime-ready form of a
// preset.IdleDetection block. Built once at child spawn and held
// read-only by the classifier; regex patterns are compiled here so the
// hot path doesn't pay for it.
type resolvedIdleDetection struct {
strategy IdleStrategy
idleThresholdMS int64
titleStatusMap map[string]IdleState
permissionRegexes []*regexp.Regexp
thinkingRegexes []*regexp.Regexp
errorRegexes []*regexp.Regexp
}
// resolveIdleDetection compiles a preset.IdleDetection (which may be
// nil) into the runtime form. Unknown strategies fall back to
// output_activity. Pattern compile errors are skipped silently — the
// preset loader is responsible for surfacing them as warnings.
func resolveIdleDetection(cfg *preset.IdleDetection) *resolvedIdleDetection {
r := &resolvedIdleDetection{
strategy: StrategyOutputActivity,
idleThresholdMS: defaultIdleThresholdMS,
}
if cfg == nil {
return r
}
switch IdleStrategy(cfg.Strategy) {
case StrategyOSCTitleStability, StrategyOSCTitleStatus, StrategyOutputActivity:
r.strategy = IdleStrategy(cfg.Strategy)
}
if cfg.IdleThresholdMS > 0 {
r.idleThresholdMS = int64(cfg.IdleThresholdMS)
}
if len(cfg.TitleStatusMap) > 0 {
r.titleStatusMap = make(map[string]IdleState, len(cfg.TitleStatusMap))
for k, v := range cfg.TitleStatusMap {
switch IdleState(v) {
case StateIdle, StateWorking, StateThinking, StatePermission, StateError:
r.titleStatusMap[k] = IdleState(v)
}
}
}
r.permissionRegexes = compilePatterns(cfg.PermissionPatterns)
r.thinkingRegexes = compilePatterns(cfg.ThinkingPatterns)
r.errorRegexes = compilePatterns(cfg.ErrorPatterns)
return r
}
func compilePatterns(ps []string) []*regexp.Regexp {
if len(ps) == 0 {
return nil
}
out := make([]*regexp.Regexp, 0, len(ps))
for _, p := range ps {
if p == "" {
continue
}
re, err := regexp.Compile(p)
if err != nil {
continue
}
out = append(out, re)
}
return out
}
// classify computes the IdleState from the inputs the classifier loop
// has already gathered. Pure function so it's easy to unit-test.
//
// Resolution order:
// 1. terminal: process exited non-zero → error (latched)
// 2. error-promoter regex match in recent output → error
// 3. permission-promoter regex match → permission
// 4. thinking-promoter regex match → thinking
// 5. strategy-specific base classification (idle vs working).
//
// inputs:
// - exited: whether the child process has exited
// - exitNonZero: whether the exit was non-zero (only meaningful when exited)
// - idleMS: ms since the last PTY output
// - titleIdleMS: ms since the last OSC title change (0 if no title yet)
// - title: current OSC title
// - tail: recent output bytes for regex matching
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail []byte) (IdleState, string) {
if exited {
if exitNonZero {
return StateError, "process exited non-zero"
}
return StateIdle, "process exited cleanly"
}
if cfg == nil {
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
}
if len(tail) > 0 {
if matchAny(cfg.errorRegexes, tail) {
return StateError, "error regex matched"
}
if matchAny(cfg.permissionRegexes, tail) {
return StatePermission, "permission regex matched"
}
if matchAny(cfg.thinkingRegexes, tail) {
return StateThinking, "thinking regex matched"
}
}
threshold := cfg.idleThresholdMS
switch cfg.strategy {
case StrategyOSCTitleStatus:
// First try the title-status map; if no match, fall back to
// title-stability behaviour so we still produce idle/working.
if s, ok := matchTitleStatus(cfg.titleStatusMap, title); ok {
return s, "title status match"
}
fallthrough
case StrategyOSCTitleStability:
// If we've never seen a title, fall back to output activity so
// we don't latch in idle while the child is clearly running.
if titleIdleMS == 0 {
return baseStateFromIdleMS(idleMS, threshold)
}
return baseStateFromIdleMS(titleIdleMS, threshold)
default: // output_activity
return baseStateFromIdleMS(idleMS, threshold)
}
}
func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) {
// idleMS == 0 means "no writes yet" (per Child.IdleMS) — treat as
// not-idle so we don't classify a freshly-spawned child as idle.
if idleMS == 0 {
return StateWorking, "no activity yet"
}
if idleMS < threshold {
return StateWorking, "recent activity"
}
return StateIdle, "quiet for threshold"
}
func matchAny(res []*regexp.Regexp, tail []byte) bool {
for _, re := range res {
if re.Match(tail) {
return true
}
}
return false
}
func matchTitleStatus(m map[string]IdleState, title string) (IdleState, bool) {
if len(m) == 0 || title == "" {
return StateUnknown, false
}
for k, v := range m {
if k == "" {
continue
}
if containsFold(title, k) {
return v, true
}
}
return StateUnknown, false
}
// containsFold reports whether s contains sub, case-insensitively.
// Cheap implementation suitable for short titles.
func containsFold(s, sub string) bool {
if len(sub) == 0 {
return true
}
if len(sub) > len(s) {
return false
}
ls, lsub := lower(s), lower(sub)
for i := 0; i+len(lsub) <= len(ls); i++ {
if ls[i:i+len(lsub)] == lsub {
return true
}
}
return false
}
func lower(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + 32
}
}
return string(b)
}

112
internal/app/idle_test.go Normal file
View File

@@ -0,0 +1,112 @@
package app
import (
"regexp"
"testing"
)
func mustCompile(t *testing.T, p string) *regexp.Regexp {
t.Helper()
re, err := regexp.Compile(p)
if err != nil {
t.Fatalf("regex %q: %v", p, err)
}
return re
}
func TestClassifyOutputActivity(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
cases := []struct {
name string
idleMS int64
want IdleState
}{
{"fresh-spawn no writes", 0, StateWorking},
{"recent activity", 500, StateWorking},
{"under threshold", 1999, StateWorking},
{"at threshold", 2000, StateIdle},
{"over threshold", 5000, StateIdle},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil)
if got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
})
}
}
func TestClassifyTitleStability(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000}
// Title change recent → working.
if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil); got != StateWorking {
t.Fatalf("recent title change: got %q", got)
}
// Title stable past threshold → idle.
if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil); got != StateIdle {
t.Fatalf("stable title: got %q", got)
}
// No title yet: fall back to output activity.
if got, _ := classify(cfg, false, false, 100, 0, "", nil); got != StateWorking {
t.Fatalf("no title yet, recent output: got %q", got)
}
if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle {
t.Fatalf("no title yet, output idle: got %q", got)
}
}
func TestClassifyTitleStatus(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStatus,
idleThresholdMS: 2000,
titleStatusMap: map[string]IdleState{
"thinking": StateThinking,
"permission": StatePermission,
"error": StateError,
},
}
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking {
t.Fatalf("thinking title: got %q", got)
}
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil); got != StatePermission {
t.Fatalf("permission title: got %q", got)
}
// No match in map → fall back to stability.
if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil); got != StateIdle {
t.Fatalf("unmatched title, stable: got %q", got)
}
}
func TestClassifyPromoterRegex(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOutputActivity,
idleThresholdMS: 2000,
permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)},
errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)},
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)},
}
// Permission promoter beats idle.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); got != StatePermission {
t.Fatalf("permission promoter: got %q", got)
}
// Error trumps permission.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?")); got != StateError {
t.Fatalf("error promoter beats permission: got %q", got)
}
// Thinking promoter on idle output.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…")); got != StateThinking {
t.Fatalf("thinking promoter: got %q", got)
}
}
func TestClassifyExitTerminal(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
if got, _ := classify(cfg, true, true, 0, 0, "", nil); got != StateError {
t.Fatalf("non-zero exit: got %q", got)
}
if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle {
t.Fatalf("clean exit: got %q", got)
}
}

View File

@@ -140,6 +140,7 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
cleanup()
return nil, err
}
c.setIdleDetection(resolveIdleDetection(p.IdleDetection))
// Wait for the preset's ready signal, then type the initial prompt.
idle := time.Duration(1000) * time.Millisecond
@@ -171,7 +172,7 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s
env = append(env, k+"="+v)
}
cols, rows := l.size()
return l.sess.Spawn(SpawnSpec{
c, err := l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: p.ResolvedArgv(),
Env: env,
@@ -180,6 +181,11 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s
WorkDir: p.WorkingDir,
PresetRef: p.Name,
}, cols, rows)
if err != nil {
return nil, err
}
c.setIdleDetection(resolveIdleDetection(p.IdleDetection))
return c, nil
}
// LaunchCommandArgv spawns a freeform-argv command entry. Trust gating

View File

@@ -70,6 +70,10 @@ type ChildEventListener interface {
// Only the focused-child chunk should reach the screen — the TUI
// filters by id.
OnPTYOut(childID string, chunk []byte)
// OnChildStateChanged fires when the idle-detection classifier
// updates a child's IdleState. Listeners use this to repaint the
// sidebar badge and to evaluate idle-aware timers.
OnChildStateChanged(childID string, state IdleState)
}
func NewSession(projectDir, projectKey string) *Session {
@@ -140,6 +144,12 @@ func (s *Session) emitPTYOut(id string, chunk []byte) {
}
}
func (s *Session) emitStateChanged(id string, state IdleState) {
for _, l := range s.listenersSnapshot() {
l.OnChildStateChanged(id, state)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
@@ -374,6 +384,15 @@ func (s *Session) pumpChild(c *Child, runID uint64) {
if _, werr := em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr)
}
// OSC 0/2 title updates ride on the same byte stream as
// the rest of the output. Polling the emulator after each
// Write is cheap (one cgo call returning a borrowed
// string) and lets the classifier treat title changes as
// an activity signal — even when the title isn't visible
// in the rendered grid.
if t, terr := em.Title(); terr == nil {
c.recordTitle(t)
}
}
c.recordWrite(chunk)
s.emitPTYOut(c.ID, chunk)

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"
)
const (
@@ -11,6 +12,24 @@ const (
statusRows = 1
)
// formatShortDuration renders a duration as a short, sidebar-friendly
// suffix: ms under 1s, "12s" under 60s, "3m" otherwise.
func formatShortDuration(d time.Duration) string {
if d <= 0 {
return "0s"
}
if d < time.Second {
return fmt.Sprintf("%dms", int(d/time.Millisecond))
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d/time.Second))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d/time.Minute))
}
return fmt.Sprintf("%dh", int(d/time.Hour))
}
// drawSidebar paints the right-rail session tree + scratchpad list.
// SPEC §4: the rail is the active session's child hierarchy on top and
// the scratchpad list (with preview) on the bottom.
@@ -62,14 +81,56 @@ func (st *uiState) drawSidebar() {
write(" " + styleActive + text + styleReset)
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
}
// timerIndicator returns a short " ⏱ 12s" or " ⏸ paused" suffix
// when c has a pending or paused timer attached (owns or watches).
// Empty string when no timer is in play.
timerIndicator := func(c *Child) string {
if st.timers == nil {
return ""
}
info := st.timers.activeForChild(c.ID)
if info == nil {
return ""
}
if info.Status == timerStatusPaused {
return " " + styleDim + "⏸" + styleReset
}
remaining := ""
if info.FiresAtUnixMS > 0 {
d := time.Until(time.UnixMilli(info.FiresAtUnixMS))
if d < 0 {
d = 0
}
remaining = formatShortDuration(d)
}
return " " + styleDim + "⏱" + styleReset + " " + styleHint + remaining + styleReset
}
statusGlyph := func(c *Child, focused bool) string {
if c.Status() != StatusRunning {
return styleDim + "○" + styleReset
}
// Idle-detection states paint over the plain running glyph so
// the rail communicates "running but waiting on you" vs "running
// and busy" at a glance. Focused entries always use the accent
// colour so the user's selection stays visible.
style := styleHint
if focused {
return styleAccent + "●" + styleReset
style = styleAccent
}
switch c.IdleState() {
case StateError:
return styleError + "✕" + styleReset
case StatePermission:
return styleAccent + "?" + styleReset
case StateThinking:
return style + "◐" + styleReset
case StateIdle:
return style + "○" + styleReset
case StateWorking:
return style + "●" + styleReset
default:
return style + "●" + styleReset
}
return styleHint + "●" + styleReset
}
// Processes section — top-level command/terminal processes,
@@ -92,9 +153,9 @@ func (st *uiState) drawSidebar() {
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " +
styleBold + c.DisplayName() + styleReset + marker
styleBold + c.DisplayName() + styleReset + marker + timerIndicator(c)
} else {
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker + timerIndicator(c)
}
write(line)
}
@@ -124,9 +185,9 @@ func (st *uiState) drawSidebar() {
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " +
styleBold + c.DisplayName() + styleReset
styleBold + c.DisplayName() + styleReset + timerIndicator(c)
} else {
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset + timerIndicator(c)
}
write(line)
}

View File

@@ -11,4 +11,5 @@ const (
styleAccent = "\x1b[38;5;75m"
styleHint = "\x1b[38;5;244m"
styleActive = "\x1b[1;38;5;253m"
styleError = "\x1b[38;5;203m"
)

488
internal/app/timers.go Normal file
View 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
}

223
internal/app/timers_test.go Normal file
View 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")
}
}