Fix idle timer review issues
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -108,6 +108,25 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
renders the canonical `--flag` form.
|
renders the canonical `--flag` form.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- `whoami` and `help("timers")` now advertise the full Solo-parity timer
|
||||||
|
surface (`timer_set`, `timer_fire_when_idle_any`,
|
||||||
|
`timer_fire_when_idle_all`, `timer_cancel`, `timer_pause`,
|
||||||
|
`timer_resume`, `timer_list`) so agents using either tool for
|
||||||
|
orientation discover them — previously only `timer_wait` was listed.
|
||||||
|
- Resuming a paused idle-aware timer now re-checks the satisfaction
|
||||||
|
condition. Previously, if every watched process became idle (or, for
|
||||||
|
`idle_any`, any non-baseline watcher went idle) while the timer was
|
||||||
|
paused, the timer stayed pending forever because no further state
|
||||||
|
transitions were observed.
|
||||||
|
- Fired and canceled timers are now removed from the timer registry,
|
||||||
|
so long-running patterm sessions no longer accumulate completed
|
||||||
|
timer records and message bodies. `timer_list` and the sidebar
|
||||||
|
indicator already filtered them out; only the in-memory leak is
|
||||||
|
fixed.
|
||||||
|
- Per-preset idle-detection config is now installed through `SpawnSpec`
|
||||||
|
before the child is published to the session, closing a race in
|
||||||
|
which the classifier goroutine could observe a freshly spawned
|
||||||
|
process before its preset's classifier strategy was attached.
|
||||||
- Opening the command palette while a scratchpad was focused left the
|
- Opening the command palette while a scratchpad was focused left the
|
||||||
palette wedged — typing did nothing and Esc left the palette's top
|
palette wedged — typing did nothing and Esc left the palette's top
|
||||||
border drawn over the pad until you closed the pad with Ctrl-W and
|
border drawn over the pad until you closed the pad with Ctrl-W and
|
||||||
|
|||||||
11
TODO.md
11
TODO.md
@@ -1,3 +1,14 @@
|
|||||||
|
- [ ] Codex seemed to think that it needed to launch patterm itself to get the mcp working
|
||||||
|
- [ ] I cant click and drag to select text from codex
|
||||||
|
- [ ] codex uses perl to interact with the socket rather than calling mcp tools
|
||||||
|
- when it _did_ open a sub claude it opened it as a separate tab rather than a sub-agent.
|
||||||
|
- [ ] codex rendering is VERY slow
|
||||||
|
- maybe we need to use diffing rather than rendering the entire viewport for performance
|
||||||
|
- We should add a --debug and --profile flag, so we can get detailed performance data and full logs of the agent output to be debugged later on.
|
||||||
|
- I don't mind what format this is in, ideally easy for LLMs to understand
|
||||||
|
- [ ] Resuming a long claude session takes a couple of seconds for the entire buffer to load in, it looks like it's scrolling down for a couple seconds.
|
||||||
|
- In raw alacritty this is instant, so there's some sort of performance issue with patterm's terminal emulation.
|
||||||
|
|
||||||
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD]
|
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD]
|
||||||
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
|
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
|
||||||
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
|
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, pres
|
|||||||
// rather than make it implement the full surface.
|
// rather than make it implement the full surface.
|
||||||
type timerListenerAdapter struct{ m *timerManager }
|
type timerListenerAdapter struct{ m *timerManager }
|
||||||
|
|
||||||
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
|
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
|
||||||
func (a timerListenerAdapter) OnChildExited(*Child) {}
|
func (a timerListenerAdapter) OnChildExited(*Child) {}
|
||||||
func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
|
func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
|
||||||
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
|
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
|
||||||
a.m.onChildStateChanged(id, st)
|
a.m.onChildStateChanged(id, st)
|
||||||
}
|
}
|
||||||
@@ -1084,7 +1084,9 @@ func availableToolsForRole(role mcp.CallerRole) []string {
|
|||||||
"list_processes", "get_process_status", "get_project_status",
|
"list_processes", "get_process_status", "get_project_status",
|
||||||
"get_process_output", "get_process_raw_output", "search_output",
|
"get_process_output", "get_process_raw_output", "search_output",
|
||||||
"wait_for_pattern", "get_process_ports",
|
"wait_for_pattern", "get_process_ports",
|
||||||
"send_input", "send_message", "request_human_attention", "timer_wait",
|
"send_input", "send_message", "request_human_attention",
|
||||||
|
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
|
||||||
|
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
|
||||||
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append",
|
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append",
|
||||||
"whoami", "help",
|
"whoami", "help",
|
||||||
}
|
}
|
||||||
@@ -1114,8 +1116,8 @@ func helpFor(topic string) mcp.HelpResponse {
|
|||||||
}
|
}
|
||||||
case "lifecycle":
|
case "lifecycle":
|
||||||
return mcp.HelpResponse{
|
return mcp.HelpResponse{
|
||||||
Topic: "lifecycle",
|
Topic: "lifecycle",
|
||||||
Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.",
|
Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.",
|
||||||
RelatedTools: []string{"close_process", "stop_process", "start_process", "list_processes", "get_process_status"},
|
RelatedTools: []string{"close_process", "stop_process", "start_process", "list_processes", "get_process_status"},
|
||||||
}
|
}
|
||||||
case "inspection":
|
case "inspection":
|
||||||
@@ -1144,9 +1146,18 @@ func helpFor(topic string) mcp.HelpResponse {
|
|||||||
}
|
}
|
||||||
case "timers":
|
case "timers":
|
||||||
return mcp.HelpResponse{
|
return mcp.HelpResponse{
|
||||||
Topic: "timers",
|
Topic: "timers",
|
||||||
Content: "timer_wait returns a timer_id immediately and injects `[system] Your timer [<label>] has completed.` into your pane when it fires. Use it instead of sleeping in your own process.",
|
Content: "Timers fire by injecting your chosen body (or a default `[system] Your timer […] has completed.` line) back into your pane as a fresh user turn. Use them instead of sleeping in your own process. " +
|
||||||
RelatedTools: []string{"timer_wait"},
|
"timer_wait / timer_set schedule a delay timer (timer_set lets you set body+label). " +
|
||||||
|
"timer_fire_when_idle_any fires when any watched process becomes idle (already-idle watchers are excluded from the baseline). " +
|
||||||
|
"timer_fire_when_idle_all fires when every watched process is idle; if all are idle at registration the response is already_satisfied with no pending timer. " +
|
||||||
|
"timer_cancel / timer_pause / timer_resume manage outstanding timers; resume re-checks idle conditions in case a watcher went idle while paused. " +
|
||||||
|
"timer_list shows your pending and paused timers.",
|
||||||
|
RelatedTools: []string{
|
||||||
|
"timer_wait", "timer_set",
|
||||||
|
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
|
||||||
|
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
case "readiness":
|
case "readiness":
|
||||||
return mcp.HelpResponse{
|
return mcp.HelpResponse{
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hjbdev/patterm/internal/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mkChild builds a Child without starting a PTY. Use sparingly — the
|
// mkChild builds a Child without starting a PTY. Use sparingly — the
|
||||||
@@ -164,6 +166,47 @@ func TestHelpSpawningPointsAtLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAvailableToolsAdvertisesAllTimerTools makes sure orchestrators
|
||||||
|
// and sub-agents discover the full timer surface via whoami — not just
|
||||||
|
// timer_wait. Otherwise agents using whoami for orientation would never
|
||||||
|
// learn about timer_set, timer_fire_when_idle_*, timer_pause/resume,
|
||||||
|
// timer_cancel, and timer_list.
|
||||||
|
func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) {
|
||||||
|
want := []string{
|
||||||
|
"timer_wait", "timer_set",
|
||||||
|
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
|
||||||
|
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
|
||||||
|
}
|
||||||
|
for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} {
|
||||||
|
tools := availableToolsForRole(role)
|
||||||
|
for _, w := range want {
|
||||||
|
if !containsString(tools, w) {
|
||||||
|
t.Fatalf("role %q missing %q in available tools: %v", role, w, tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHelpTimersDocumentsAllTools mirrors the whoami check for the
|
||||||
|
// help("timers") topic — the related-tools list must enumerate every
|
||||||
|
// timer_* tool so callers reading help can dispatch them.
|
||||||
|
func TestHelpTimersDocumentsAllTools(t *testing.T) {
|
||||||
|
resp := helpFor("timers")
|
||||||
|
if resp.Topic != "timers" {
|
||||||
|
t.Fatalf("topic: %q", resp.Topic)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"timer_wait", "timer_set",
|
||||||
|
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
|
||||||
|
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
|
||||||
|
}
|
||||||
|
for _, w := range want {
|
||||||
|
if !containsString(resp.RelatedTools, w) {
|
||||||
|
t.Fatalf("timers help missing %q in related tools: %v", w, resp.RelatedTools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func containsString(haystack []string, needle string) bool {
|
func containsString(haystack []string, needle string) bool {
|
||||||
for _, s := range haystack {
|
for _, s := range haystack {
|
||||||
if s == needle {
|
if s == needle {
|
||||||
@@ -172,4 +215,3 @@ func containsString(haystack []string, needle string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,20 +127,20 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
|
|||||||
// Spawn with the chosen identity.
|
// Spawn with the chosen identity.
|
||||||
cols, rows := l.size()
|
cols, rows := l.size()
|
||||||
c, err := l.sess.Spawn(SpawnSpec{
|
c, err := l.sess.Spawn(SpawnSpec{
|
||||||
Kind: KindAgent,
|
Kind: KindAgent,
|
||||||
Argv: argv,
|
Argv: argv,
|
||||||
Env: env,
|
Env: env,
|
||||||
Name: displayName,
|
Name: displayName,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
PresetRef: p.Name,
|
PresetRef: p.Name,
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
CleanupPaths: cleanupPaths,
|
CleanupPaths: cleanupPaths,
|
||||||
|
IdleDetection: resolveIdleDetection(p.IdleDetection),
|
||||||
}, cols, rows)
|
}, cols, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.setIdleDetection(resolveIdleDetection(p.IdleDetection))
|
|
||||||
|
|
||||||
// Wait for the preset's ready signal, then type the initial prompt.
|
// Wait for the preset's ready signal, then type the initial prompt.
|
||||||
idle := time.Duration(1000) * time.Millisecond
|
idle := time.Duration(1000) * time.Millisecond
|
||||||
@@ -173,18 +173,18 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s
|
|||||||
}
|
}
|
||||||
cols, rows := l.size()
|
cols, rows := l.size()
|
||||||
c, err := l.sess.Spawn(SpawnSpec{
|
c, err := l.sess.Spawn(SpawnSpec{
|
||||||
Kind: KindCommand,
|
Kind: KindCommand,
|
||||||
Argv: p.ResolvedArgv(),
|
Argv: p.ResolvedArgv(),
|
||||||
Env: env,
|
Env: env,
|
||||||
Name: displayName,
|
Name: displayName,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
WorkDir: p.WorkingDir,
|
WorkDir: p.WorkingDir,
|
||||||
PresetRef: p.Name,
|
PresetRef: p.Name,
|
||||||
|
IdleDetection: resolveIdleDetection(p.IdleDetection),
|
||||||
}, cols, rows)
|
}, cols, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.setIdleDetection(resolveIdleDetection(p.IdleDetection))
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,11 @@ type SpawnSpec struct {
|
|||||||
// or is closed. They must be attached before the PTY starts so a
|
// or is closed. They must be attached before the PTY starts so a
|
||||||
// fast-exiting child cannot outrun cleanup registration.
|
// fast-exiting child cannot outrun cleanup registration.
|
||||||
CleanupPaths []string
|
CleanupPaths []string
|
||||||
|
// IdleDetection is the resolved per-preset idle classifier config.
|
||||||
|
// Must be installed before the child is published to s.children so
|
||||||
|
// the classifier goroutine never observes a nil/default config for
|
||||||
|
// a preset that overrides it.
|
||||||
|
IdleDetection *resolvedIdleDetection
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn creates a new entry and starts its PTY. For Kind = command the
|
// Spawn creates a new entry and starts its PTY. For Kind = command the
|
||||||
@@ -208,6 +213,12 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
|
|||||||
for _, path := range spec.CleanupPaths {
|
for _, path := range spec.CleanupPaths {
|
||||||
c.AddCleanupPath(path)
|
c.AddCleanupPath(path)
|
||||||
}
|
}
|
||||||
|
// Install idle-detection BEFORE publishing to s.children — otherwise
|
||||||
|
// the classifier goroutine could read c.idleDetection while the
|
||||||
|
// launcher is still racing to set it.
|
||||||
|
if spec.IdleDetection != nil {
|
||||||
|
c.setIdleDetection(spec.IdleDetection)
|
||||||
|
}
|
||||||
runID, err := c.startPTY(cols, rows)
|
runID, err := c.startPTY(cols, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.cleanupOwnedPaths()
|
c.cleanupOwnedPaths()
|
||||||
|
|||||||
@@ -57,6 +57,50 @@ func TestParentExitKillsDescendants(t *testing.T) {
|
|||||||
waitUntilNotLive(t, grandchild)
|
waitUntilNotLive(t, grandchild)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSpawnInstallsIdleDetectionBeforePublish guarantees that a child
|
||||||
|
// spawned with SpawnSpec.IdleDetection has its resolved config visible
|
||||||
|
// the instant the child appears in s.children — closing the race where
|
||||||
|
// the classifier could read c.idleDetection before the launcher set it.
|
||||||
|
func TestSpawnInstallsIdleDetectionBeforePublish(t *testing.T) {
|
||||||
|
sess := NewSession(t.TempDir(), "test")
|
||||||
|
want := &resolvedIdleDetection{
|
||||||
|
strategy: StrategyOSCTitleStability,
|
||||||
|
idleThresholdMS: 9999,
|
||||||
|
}
|
||||||
|
c, err := sess.Spawn(SpawnSpec{
|
||||||
|
Kind: KindCommand,
|
||||||
|
Argv: []string{"sh", "-c", "sleep 30"},
|
||||||
|
IdleDetection: want,
|
||||||
|
}, 80, 24)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = c.signal(syscall.SIGTERM) }()
|
||||||
|
|
||||||
|
// Read back via the same access path the classifier uses
|
||||||
|
// (sess.Children) so the test fails if the field is set only
|
||||||
|
// AFTER the child is published.
|
||||||
|
var found *Child
|
||||||
|
for _, ch := range sess.Children() {
|
||||||
|
if ch.ID == c.ID {
|
||||||
|
found = ch
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("spawned child %s not in Children()", c.ID)
|
||||||
|
}
|
||||||
|
if found.idleDetection == nil {
|
||||||
|
t.Fatalf("idleDetection nil after Spawn returned")
|
||||||
|
}
|
||||||
|
if found.idleDetection.strategy != StrategyOSCTitleStability {
|
||||||
|
t.Fatalf("strategy: got %q want %q", found.idleDetection.strategy, StrategyOSCTitleStability)
|
||||||
|
}
|
||||||
|
if found.idleDetection.idleThresholdMS != 9999 {
|
||||||
|
t.Fatalf("threshold: got %d want 9999", found.idleDetection.idleThresholdMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func waitUntilLive(t *testing.T, c *Child) {
|
func waitUntilLive(t *testing.T, c *Child) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
type pendingTimerKind string
|
type pendingTimerKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
timerKindDelay pendingTimerKind = "delay"
|
timerKindDelay pendingTimerKind = "delay"
|
||||||
timerKindIdleAny pendingTimerKind = "idle_any"
|
timerKindIdleAny pendingTimerKind = "idle_any"
|
||||||
timerKindIdleAll pendingTimerKind = "idle_all"
|
timerKindIdleAll pendingTimerKind = "idle_all"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -39,7 +39,7 @@ type pendingTimer struct {
|
|||||||
kind pendingTimerKind
|
kind pendingTimerKind
|
||||||
status string
|
status string
|
||||||
|
|
||||||
watched []string
|
watched []string
|
||||||
idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction)
|
idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction)
|
||||||
|
|
||||||
firesAt time.Time
|
firesAt time.Time
|
||||||
@@ -55,9 +55,9 @@ type pendingTimer struct {
|
|||||||
type timerManager struct {
|
type timerManager struct {
|
||||||
sess *Session
|
sess *Session
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nextID int
|
nextID int
|
||||||
timers map[string]*pendingTimer
|
timers map[string]*pendingTimer
|
||||||
|
|
||||||
// fireFn is the callback used to deliver the body to the owning
|
// fireFn is the callback used to deliver the body to the owning
|
||||||
// process. Decoupled so tests can substitute a recorder. Defaults
|
// process. Decoupled so tests can substitute a recorder. Defaults
|
||||||
@@ -134,6 +134,7 @@ func (m *timerManager) fireDelay(id string) {
|
|||||||
t.status = timerStatusFired
|
t.status = timerStatusFired
|
||||||
owner := m.sess.FindChild(t.ownerID)
|
owner := m.sess.FindChild(t.ownerID)
|
||||||
body, label := t.body, t.label
|
body, label := t.body, t.label
|
||||||
|
delete(m.timers, id)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
m.fireFn(owner, body, label)
|
m.fireFn(owner, body, label)
|
||||||
}
|
}
|
||||||
@@ -228,6 +229,7 @@ func (m *timerManager) fireIdleMaxWait(id string) {
|
|||||||
t.status = timerStatusFired
|
t.status = timerStatusFired
|
||||||
owner := m.sess.FindChild(t.ownerID)
|
owner := m.sess.FindChild(t.ownerID)
|
||||||
body, label := t.body, t.label
|
body, label := t.body, t.label
|
||||||
|
delete(m.timers, id)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
m.fireFn(owner, body, label)
|
m.fireFn(owner, body, label)
|
||||||
}
|
}
|
||||||
@@ -247,6 +249,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
|||||||
label string
|
label string
|
||||||
}
|
}
|
||||||
var fires []firing
|
var fires []firing
|
||||||
|
var firedIDs []string
|
||||||
for _, t := range m.timers {
|
for _, t := range m.timers {
|
||||||
if t.status != timerStatusPending {
|
if t.status != timerStatusPending {
|
||||||
continue
|
continue
|
||||||
@@ -268,6 +271,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
|||||||
body: t.body,
|
body: t.body,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
})
|
})
|
||||||
|
firedIDs = append(firedIDs, t.id)
|
||||||
case timerKindIdleAll:
|
case timerKindIdleAll:
|
||||||
if m.allWatchedIdleLocked(t) {
|
if m.allWatchedIdleLocked(t) {
|
||||||
t.status = timerStatusFired
|
t.status = timerStatusFired
|
||||||
@@ -279,9 +283,13 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
|||||||
body: t.body,
|
body: t.body,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
})
|
})
|
||||||
|
firedIDs = append(firedIDs, t.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, id := range firedIDs {
|
||||||
|
delete(m.timers, id)
|
||||||
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
for _, f := range fires {
|
for _, f := range fires {
|
||||||
m.fireFn(f.owner, f.body, f.label)
|
m.fireFn(f.owner, f.body, f.label)
|
||||||
@@ -327,6 +335,7 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
|
|||||||
t.rt = nil
|
t.rt = nil
|
||||||
}
|
}
|
||||||
t.status = timerStatusCanceled
|
t.status = timerStatusCanceled
|
||||||
|
delete(m.timers, id)
|
||||||
return nil
|
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
|
// TimerResume re-arms a paused timer. For delay timers the remaining
|
||||||
// duration is restored; idle-* timers re-attach to the state-change
|
// duration is restored; idle-* timers re-attach to the state-change
|
||||||
// watch list, and any remaining max-wait clock resumes.
|
// 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 {
|
func (m *timerManager) TimerResume(ownerID, id string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
|
||||||
t, ok := m.timers[id]
|
t, ok := m.timers[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
m.mu.Unlock()
|
||||||
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
||||||
}
|
}
|
||||||
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
||||||
// MCP client); allow it to manage every timer in the session.
|
// MCP client); allow it to manage every timer in the session.
|
||||||
// Otherwise the caller's own id must match the timer's owner.
|
// Otherwise the caller's own id must match the timer's owner.
|
||||||
if ownerID != "" && t.ownerID != ownerID {
|
if ownerID != "" && t.ownerID != ownerID {
|
||||||
|
m.mu.Unlock()
|
||||||
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
||||||
}
|
}
|
||||||
if t.status != timerStatusPaused {
|
if t.status != timerStatusPaused {
|
||||||
|
m.mu.Unlock()
|
||||||
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id)
|
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id)
|
||||||
}
|
}
|
||||||
t.status = timerStatusPending
|
t.status = timerStatusPending
|
||||||
@@ -397,6 +415,42 @@ func (m *timerManager) TimerResume(ownerID, id string) error {
|
|||||||
t.pausedRemaining = 0
|
t.pausedRemaining = 0
|
||||||
t.pausedWasMaxWait = false
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,3 +221,193 @@ func TestTimerOwnershipEnforced(t *testing.T) {
|
|||||||
t.Fatal("expected ownership error from foreign pause")
|
t.Fatal("expected ownership error from foreign pause")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTimerResumeRechecksIdleAll covers the case where every watched
|
||||||
|
// child becomes idle while an idle_all timer is paused. Without a resume
|
||||||
|
// re-check, the timer would stay pending forever because the state
|
||||||
|
// transitions happened during the pause window.
|
||||||
|
func TestTimerResumeRechecksIdleAll(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)
|
||||||
|
working := StateWorking
|
||||||
|
a.idleState.Store(&working)
|
||||||
|
b.idleState.Store(&working)
|
||||||
|
|
||||||
|
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 != "pending" {
|
||||||
|
t.Fatalf("status: got %q want pending", resp.Status)
|
||||||
|
}
|
||||||
|
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
|
||||||
|
t.Fatalf("Pause: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both watched children become idle WHILE THE TIMER IS PAUSED, so
|
||||||
|
// onChildStateChanged is not consulted for this timer.
|
||||||
|
idle := StateIdle
|
||||||
|
a.idleState.Store(&idle)
|
||||||
|
b.idleState.Store(&idle)
|
||||||
|
|
||||||
|
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
|
||||||
|
t.Fatalf("Resume: %v", err)
|
||||||
|
}
|
||||||
|
got := rec.snapshot()
|
||||||
|
if len(got) != 1 || got[0].Body != "all done" {
|
||||||
|
t.Fatalf("expected fire on resume, got: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTimerResumeRechecksIdleAny covers the same missed-transition shape
|
||||||
|
// for idle_any: a non-baseline watched child going idle during pause must
|
||||||
|
// fire on resume.
|
||||||
|
func TestTimerResumeRechecksIdleAny(t *testing.T) {
|
||||||
|
sess, mgr, rec := newTestManager(t)
|
||||||
|
owner := fakeChild("p_owner")
|
||||||
|
a := fakeChild("p_a")
|
||||||
|
addChild(sess, owner)
|
||||||
|
addChild(sess, a)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
|
||||||
|
t.Fatalf("Pause: %v", err)
|
||||||
|
}
|
||||||
|
idle := StateIdle
|
||||||
|
a.idleState.Store(&idle)
|
||||||
|
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
|
||||||
|
t.Fatalf("Resume: %v", err)
|
||||||
|
}
|
||||||
|
got := rec.snapshot()
|
||||||
|
if len(got) != 1 || got[0].Body != "one done" {
|
||||||
|
t.Fatalf("expected fire on resume, got: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTimerResumeIdleAnyExcludesBaselineDuringPause guards against a
|
||||||
|
// resume re-check firing for a watcher that was idle at registration
|
||||||
|
// (and therefore part of the baseline) — only non-baseline transitions
|
||||||
|
// should satisfy idle_any.
|
||||||
|
func TestTimerResumeIdleAnyExcludesBaselineDuringPause(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
|
||||||
|
working := StateWorking
|
||||||
|
a.idleState.Store(&idle) // baseline: already idle
|
||||||
|
b.idleState.Store(&working) // not baseline
|
||||||
|
|
||||||
|
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TimerFireWhenIdleAny: %v", err)
|
||||||
|
}
|
||||||
|
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
|
||||||
|
t.Fatalf("Pause: %v", err)
|
||||||
|
}
|
||||||
|
// b stays working — only a is idle, and a was baseline. Resume
|
||||||
|
// must not fire.
|
||||||
|
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
|
||||||
|
t.Fatalf("Resume: %v", err)
|
||||||
|
}
|
||||||
|
if got := rec.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected fire on resume: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTimerRecordsRemovedOnFire ensures fired delay timers don't leak
|
||||||
|
// in the timer registry — bodies and metadata must be released.
|
||||||
|
func TestTimerRecordsRemovedOnFire(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)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if len(rec.snapshot()) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if len(rec.snapshot()) != 1 {
|
||||||
|
t.Fatalf("timer didn't fire")
|
||||||
|
}
|
||||||
|
mgr.mu.Lock()
|
||||||
|
_, stillThere := mgr.timers[id]
|
||||||
|
count := len(mgr.timers)
|
||||||
|
mgr.mu.Unlock()
|
||||||
|
if stillThere {
|
||||||
|
t.Fatalf("fired timer %s was not removed from registry", id)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("timer registry not drained: %d entries", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTimerRecordsRemovedOnCancel ensures canceled timers are dropped
|
||||||
|
// from the registry.
|
||||||
|
func TestTimerRecordsRemovedOnCancel(t *testing.T) {
|
||||||
|
sess, mgr, _ := newTestManager(t)
|
||||||
|
c := fakeChild("p_owner")
|
||||||
|
addChild(sess, c)
|
||||||
|
id, err := mgr.TimerSet("p_owner", "x", "", 60)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TimerSet: %v", err)
|
||||||
|
}
|
||||||
|
if err := mgr.TimerCancel("p_owner", id); err != nil {
|
||||||
|
t.Fatalf("Cancel: %v", err)
|
||||||
|
}
|
||||||
|
mgr.mu.Lock()
|
||||||
|
_, stillThere := mgr.timers[id]
|
||||||
|
mgr.mu.Unlock()
|
||||||
|
if stillThere {
|
||||||
|
t.Fatalf("canceled timer %s was not removed from registry", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTimerRecordsRemovedOnIdleFire ensures idle_* timers are dropped
|
||||||
|
// from the registry once they fire via onChildStateChanged.
|
||||||
|
func TestTimerRecordsRemovedOnIdleFire(t *testing.T) {
|
||||||
|
sess, mgr, rec := newTestManager(t)
|
||||||
|
owner := fakeChild("p_owner")
|
||||||
|
a := fakeChild("p_a")
|
||||||
|
addChild(sess, owner)
|
||||||
|
addChild(sess, a)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
idle := StateIdle
|
||||||
|
a.idleState.Store(&idle)
|
||||||
|
mgr.onChildStateChanged("p_a", StateIdle)
|
||||||
|
if got := rec.snapshot(); len(got) != 1 {
|
||||||
|
t.Fatalf("expected fire, got: %+v", got)
|
||||||
|
}
|
||||||
|
mgr.mu.Lock()
|
||||||
|
_, stillThere := mgr.timers[resp.ID]
|
||||||
|
mgr.mu.Unlock()
|
||||||
|
if stillThere {
|
||||||
|
t.Fatalf("fired idle timer %s was not removed from registry", resp.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user