Files
2ca84cc127 fix(hooks): dispatch specialistStart/Stop lifecycle hooks (#1204)
* fix(hooks): dispatch specialistStart/Stop and allow matchers

These events were documented and accepted by hooks add, but never fired.
Wire advisory dispatch from specialist accounting and allow specialist
name matchers so EXTENDING.md matches runtime behavior.

* fix(hooks): unlock before specialistStop hooks and fire once

Release accountingMu before running lifecycle hooks, and dispatch
specialistStop only when the stop event is newly recorded so concurrent
finishers cannot re-run arbitrary hook commands.

* fix(hooks): fire specialistStop without relying on session append

Decouple stop-hook dispatch from parent-session event append so swarm
members (empty parent) and TaskOutput still get exactly one hook via a
shared LifecycleHooks claim, and wire that bridge into TaskOutput.

* fix(hooks): claim specialistStop durably and gate hooks on real launches

Address the review findings on specialist lifecycle dispatch:

- specialistStop is now claimed in the parent session's event log via a
  record-once specialist_stop_hook event, sharing the accounting run-ID
  equivalence (empty runId is a catch-all). A later reaper in another
  process (TaskOutput poll, recovered TaskStop, restart) no longer re-runs
  the hook. The in-process claim remains only as fallback for swarm
  members with no parent session and for session-store errors.
- Recovered TaskStop records the terminal transition after Kill, so an
  explicitly stopped recovered task emits specialistStop exactly once.
- specialistStart fires only after the child process actually started
  (onStarted after Start()); a launch that fails before exec records
  attempt accounting but no public start/stop hooks. Reapers derive the
  same eligibility from the durable stop event's childStarted flag.

Adds a CLI startup wire-up test asserting both real entry points
(interactive TUI, headless exec) attach the lifecycle bridge to the same
LifecycleHooks the TaskOutput tool holds, fixes the stale import path on
the rebased branch, and documents the once-per-run semantics.

* fix(hooks): gate background exit accounting behind the start decision

A background child that exits immediately can run its launcher exit
callback before Executor.LaunchBackground returns, so the terminal
accounting could claim and dispatch specialistStop before runBackground
dispatched specialistStart, and the recorded stop missed the pid that
SetPID had not stored yet.

The exit callback now waits on a settled channel closed when runBackground
returns (after the start hook and SetPID), from its own goroutine so a
synchronous injected launcher cannot deadlock waiting for a signal only
the launch return can close.

Regression test: TestRunBackgroundDefersExitUntilStartDecisionCompletes
drives a synchronous fast-exit launcher and asserts start-before-stop hook
order, the recorded stop pid, and no deadlock. It fails on the previous
code with hooks "specialistStop,specialistStart".

* fix(hooks): record specialistStop only after the orphan kill succeeds

When SetPID fails after a background child has started, runBackground kills
the unrecorded child and records the stop. If TerminateProcess fails, the
child may still be running, but the branch still marked the task errored,
recorded the stop, took the durable specialist_stop_hook claim and fired
specialistStop. The hook could then run while the sub-agent was alive, and
the one-shot claim suppressed the hook on the child's real exit. Marking the
task non-running also let a TaskOutput or TaskStop reap claim the stop
before the launcher's Wait observed the exit.

On a failed kill, surface the joined error and return without the status
update or the stop. The task stays running, so reapers remain no-ops, and the
launcher's exit callback marks it exited and records the single stop when the
child ends. A successful kill keeps the immediate stop as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hooks): keep the prompt on a failed orphan kill and share one stop claim

When SetPID fails and terminating the unrecorded child also fails, the child
may still be running and the task stays running. That branch still deleted
the --file prompt, so a child that had not yet read it failed at startup.
The launcher's exit callback already removes the prompt once the child
really exits, and Runtime.Close removes tracked prompts at shutdown, so the
failed-kill branch no longer removes it.

dispatchSpecialistStopHook made two independent dispatch decisions: the
durable specialist_stop_hook claim, and the in-process claim used when the
session store errors. A won durable claim never recorded the in-process
claim, so on one bridge a transient store error before or after a durable
claim dispatched the same stop twice, breaking the documented at-most-once
per process. A won durable claim now also takes the in-process claim
(including for an ineligible stop), and the hook runs only if both are free.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-07 10:19:56 -08:00
..