Cancel pending timers when a child is closed

Stale timer bodies were re-delivered to the orchestrator pane after
the parent had already processed the sub-agent's reply and called
close_process. The timer registry held no link to the child
lifecycle, so timers owned by or watching the closed child lingered
until something triggered a fire — e.g. a trailing classifier tick
for the now-removed child.

Add an OnChildClosed hook to ChildEventListener, emit it from
Session.Close (and the terminal-corpse path in reapChild), and have
the timer manager prune the registry: cancel timers owned by the
closed child; remove the closed child from each timer's watched
list (cancel the timer outright when watched empties).

Natural exit deliberately does not route through this hook — the
classifier already emits an idle transition on exit which delivers
any legitimate "fire when sub-agent finishes" semantics exactly
once; cancelling on exit would swallow that.
This commit is contained in:
2026-05-18 12:37:32 +01:00
parent de60b93bc6
commit 34b41be1df
7 changed files with 278 additions and 4 deletions

View File

@@ -91,6 +91,12 @@ type ChildEventListener interface {
// updates a child's IdleState. Listeners use this to repaint the
// sidebar badge and to evaluate idle-aware timers.
OnChildStateChanged(childID string, state IdleState)
// OnChildClosed fires when a child is being removed from the
// session (either via close_process, or — for agent/terminal
// kinds — when the PTY exits and the entry will never be
// restarted). It signals that any pending references to childID
// (e.g. timers owned by or watching it) should be dropped.
OnChildClosed(childID string)
}
func NewSession(projectDir, projectKey string) *Session {
@@ -167,6 +173,12 @@ func (s *Session) emitStateChanged(id string, state IdleState) {
}
}
func (s *Session) emitClosed(id string) {
for _, l := range s.listenersSnapshot() {
l.OnChildClosed(id)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
@@ -374,6 +386,11 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
}
}
s.mu.Unlock()
// Notify listeners outside s.mu so they can take their own locks
// without inversion. Timer manager uses this to drop pending
// timers owned by or watching the closed child — otherwise the
// next classifier tick can deliver a stale fire to the parent.
s.emitClosed(id)
s.forgetPersisted(id)
return nil
}
@@ -486,6 +503,7 @@ func (s *Session) reapChild(c *Child, runID uint64) {
}
}
s.mu.Unlock()
s.emitClosed(c.ID)
}
}