Fix idle timer review issues

This commit is contained in:
2026-05-15 11:18:03 +01:00
parent 2b9e1ed77c
commit 543c7cc59a
9 changed files with 417 additions and 35 deletions

View File

@@ -12,9 +12,9 @@ import (
type pendingTimerKind string
const (
timerKindDelay pendingTimerKind = "delay"
timerKindIdleAny pendingTimerKind = "idle_any"
timerKindIdleAll pendingTimerKind = "idle_all"
timerKindDelay pendingTimerKind = "delay"
timerKindIdleAny pendingTimerKind = "idle_any"
timerKindIdleAll pendingTimerKind = "idle_all"
)
const (
@@ -39,7 +39,7 @@ type pendingTimer struct {
kind pendingTimerKind
status string
watched []string
watched []string
idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction)
firesAt time.Time
@@ -55,9 +55,9 @@ type pendingTimer struct {
type timerManager struct {
sess *Session
mu sync.Mutex
nextID int
timers map[string]*pendingTimer
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
@@ -134,6 +134,7 @@ func (m *timerManager) fireDelay(id string) {
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)
}
@@ -228,6 +229,7 @@ func (m *timerManager) fireIdleMaxWait(id string) {
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)
}
@@ -247,6 +249,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
label string
}
var fires []firing
var firedIDs []string
for _, t := range m.timers {
if t.status != timerStatusPending {
continue
@@ -268,6 +271,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
body: t.body,
label: t.label,
})
firedIDs = append(firedIDs, t.id)
case timerKindIdleAll:
if m.allWatchedIdleLocked(t) {
t.status = timerStatusFired
@@ -279,9 +283,13 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
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)
@@ -327,6 +335,7 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
return nil
}
@@ -367,20 +376,29 @@ func (m *timerManager) TimerPause(ownerID, id string) error {
// 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()
defer m.mu.Unlock()
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
@@ -397,6 +415,42 @@ func (m *timerManager) TimerResume(ownerID, id string) error {
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
}