Files
patterm/internal/app/timers.go

543 lines
15 KiB
Go

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
delete(m.timers, id)
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
delete(m.timers, id)
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
var firedIDs []string
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,
})
firedIDs = append(firedIDs, t.id)
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,
})
firedIDs = append(firedIDs, t.id)
}
}
}
for _, id := range firedIDs {
delete(m.timers, id)
}
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
delete(m.timers, id)
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.
//
// Idle-* timers also re-check their satisfaction condition immediately
// on resume: idle transitions that occurred while paused are otherwise
// missed (onChildStateChanged only sees future flips), so a child that
// went idle during the pause window would never fire the timer. For
// idle_any we look for any non-baseline watched child currently idle;
// for idle_all we check whether every watched child is now idle.
func (m *timerManager) TimerResume(ownerID, id string) error {
m.mu.Lock()
t, ok := m.timers[id]
if !ok {
m.mu.Unlock()
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 {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status != timerStatusPaused {
m.mu.Unlock()
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
}
// For idle-* timers, evaluate the condition right now in case a
// watched child went idle while paused.
var fireNow bool
var owner *Child
var body, label string
switch t.kind {
case timerKindIdleAny:
for _, wid := range t.watched {
if t.idleBaseline[wid] {
continue
}
c := m.sess.FindChild(wid)
if c != nil && isIdleState(c.IdleState()) {
fireNow = true
break
}
}
case timerKindIdleAll:
if m.allWatchedIdleLocked(t) {
fireNow = true
}
}
if fireNow {
t.status = timerStatusFired
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
owner = m.sess.FindChild(t.ownerID)
body, label = t.body, t.label
delete(m.timers, id)
}
m.mu.Unlock()
if fireNow {
m.fireFn(owner, body, label)
}
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
}