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).
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 main2026-05-18 12:46:51 +01:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
OnChildClosedtoChildEventListenerand emits it fromSession.Close(and the terminal-corpse path inreapChild).timerManager.onChildClosedcancels 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).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