561b053f79
A fan-out of N in-process subagents used to add one sleeping daemon thread per delegated child (delegate heartbeat, 30s) and one or two per active turn (durable turn-lease refresher; turn-liveness watchdog). A profiled session with ~130 children was carrying ~1000 threads. All of these timers now run on a single process-wide daemon thread. - agent/periodic_scheduler.py (new): heap-ordered periodic scheduler on one Condition-driven daemon thread. schedule(fn, interval) -> handle; handle.cancel(wait=) blocks for an in-flight run like the old join. A callback returning False stops itself; a raising callback is logged at debug and rescheduled, so one bad timer cannot kill the rest. - tools/delegate_tool.py: _heartbeat_loop body -> _heartbeat_tick, scheduled at _HEARTBEAT_INTERVAL; stale-cycle closure state and idle/in-tool thresholds unchanged; cancel(wait=5) in finally where the stop-event + join(5) lived. - run_agent.py: _refresh_durable_turn_lease body scheduled at _lease_refresh_interval; lease-lost / refresh-error interrupt paths and the stop-event fencing are unchanged; the join(timeout=1.0) is now cancel(wait=1.0) so the interrupt clear still runs after any in-flight tick. - agent/turn_liveness.py: TurnLivenessWatchdog.make_thread/start -> schedule(); the poll body is _tick(), same sampling state machine. Bench (evals/fanout_resource_bench.py, 30 children / 10 worktrees, ok=30/30 both): peak threads 168 -> 132. At peak the old tree held 30 "Thread-N (_heartbeat_loop)" threads; the new one holds zero plus one "hermes-periodic-scheduler".