1- import { Effect , Option } from "effect"
1+ import { Cause , Effect , Fiber , Option } from "effect"
22import { Bus } from "@/bus"
33import { Permission } from "@/permission"
44import { Question } from "@/question"
@@ -86,8 +86,44 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
8686 }
8787 }
8888
89+ // bus.subscribeCallback wraps the callback in an Effect.tryPromise-based
90+ // subscription handler, so a Promise-returning callback (like Effect.runPromise)
91+ // serializes handler completion per subscription. runFork returns a Fiber
92+ // synchronously (non-thenable), unblocking dispatch so descendant question/
93+ // permission events are processed concurrently — important for long-running
94+ // subagent loops with many simultaneous descendants. Defects inside the forked
95+ // fiber do not surface through that subscription callback wrapper, so log them
96+ // here instead. Track in-flight fibers so unsubscribe() can interrupt them and
97+ // bound handler work to the RunEvents lifecycle.
98+ const inflight = new Set < Fiber . Fiber < void > > ( )
99+ let closed = false
100+ const fork = ( effect : Effect . Effect < void > ) => {
101+ if ( closed ) {
102+ // unsubscribe() already ran but bus subscription teardown is async, so
103+ // a late callback can still reach fork(). Skip starting the handler
104+ // entirely so no side effects (bump, reject, reply) leak past teardown.
105+ // Returning undefined (not a Promise) still unblocks the bus dispatch
106+ // wrapper without spawning a no-op fiber.
107+ return
108+ }
109+ const fiber = Effect . runFork (
110+ effect . pipe (
111+ Effect . tapCause ( ( cause ) =>
112+ Cause . hasInterruptsOnly ( cause )
113+ ? Effect . void
114+ : Effect . sync ( ( ) => log . error ( "handler failed" , { cause } ) ) ,
115+ ) ,
116+ ) ,
117+ )
118+ inflight . add ( fiber )
119+ // Register cleanup outside the forked effect to avoid a TDZ/race between
120+ // synchronous fiber completion and inflight.add — Fiber.await observes
121+ // completion regardless of how fast the fiber runs.
122+ Effect . runFork ( Fiber . await ( fiber ) . pipe ( Effect . ensuring ( Effect . sync ( ( ) => inflight . delete ( fiber ) ) ) ) )
123+ }
124+
89125 const unsubQuestion = yield * bus . subscribeCallback ( Question . Event . Asked , ( evt ) =>
90- Effect . runPromise (
126+ fork (
91127 Effect . gen ( function * ( ) {
92128 const mine = yield * isDescendant ( evt . properties . sessionID )
93129 if ( ! mine ) return
@@ -98,7 +134,7 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
98134 )
99135
100136 const unsubPermission = yield * bus . subscribeCallback ( Permission . Event . Asked , ( evt ) =>
101- Effect . runPromise (
137+ fork (
102138 Effect . gen ( function * ( ) {
103139 const mine = yield * isDescendant ( evt . properties . sessionID )
104140 if ( ! mine ) return
@@ -113,8 +149,13 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
113149 )
114150
115151 const unsubscribe = ( ) => {
152+ closed = true
116153 unsubQuestion ( )
117154 unsubPermission ( )
155+ inflight . forEach ( ( fiber ) => Effect . runFork ( Fiber . interrupt ( fiber ) ) )
156+ // Don't clear() — let the per-fiber Fiber.await observers remove entries
157+ // as their interrupts settle, so any stragglers caught by the closed-flag
158+ // branch above still get cleaned up correctly.
118159 }
119160
120161 return { stats, unsubscribe } satisfies Handle
0 commit comments