Cancel pending timers when a child is closed #6

Merged
harry merged 1 commits from worktree-timers-cancel-on-close into main 2026-05-18 12:46:51 +01:00
Owner

Summary

  • Adds OnChildClosed to ChildEventListener and emits it from Session.Close (and the terminal-corpse path in reapChild).
  • timerManager.onChildClosed cancels timers owned by the closed child and prunes the closed child from every watcher list; if a watched list empties, the timer is cancelled outright (no synthetic fire).
  • Natural exit is deliberately not routed through the new hook: the classifier still emits one idle transition on exit, which delivers any legitimate "fire when the sub-agent finishes" semantics exactly once. Cancelling on exit would swallow that.

Why

Stale timer bodies were being re-delivered to the orchestrator pane after the parent had already processed the sub-agent's reply and called `close_process`. The timer registry had no link to child lifecycle, so any lingering timer plus a trailing classifier tick for the now-removed child re-fired the body.

Test plan

  • `go test ./...`
  • `go test -race ./internal/app/... ./internal/harness/...`
  • New tests in `internal/app/timers_test.go` cover: partial-prune of `idle_any` watched, cancel when last watched is closed, partial-prune for `idle_all`, delay-timer-owned-by-closed-child, and the exact reported shape (parent watching a sub-agent that gets `close_process`'d — no stale body).
## Summary - Adds `OnChildClosed` to `ChildEventListener` and emits it from `Session.Close` (and the terminal-corpse path in `reapChild`). - `timerManager.onChildClosed` cancels timers owned by the closed child and prunes the closed child from every watcher list; if a watched list empties, the timer is cancelled outright (no synthetic fire). - Natural exit is deliberately **not** routed through the new hook: the classifier still emits one idle transition on exit, which delivers any legitimate "fire when the sub-agent finishes" semantics exactly once. Cancelling on exit would swallow that. ## Why Stale timer bodies were being re-delivered to the orchestrator pane after the parent had already processed the sub-agent's reply and called \`close_process\`. The timer registry had no link to child lifecycle, so any lingering timer plus a trailing classifier tick for the now-removed child re-fired the body. ## Test plan - [x] \`go test ./...\` - [x] \`go test -race ./internal/app/... ./internal/harness/...\` - [x] New tests in \`internal/app/timers_test.go\` cover: partial-prune of \`idle_any\` watched, cancel when last watched is closed, partial-prune for \`idle_all\`, delay-timer-owned-by-closed-child, and the exact reported shape (parent watching a sub-agent that gets \`close_process\`'d — no stale body).
harry added 1 commit 2026-05-18 12:37:57 +01:00
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.
harry merged commit 412b1167a2 into main 2026-05-18 12:46:51 +01:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: harry/patterm#6