5050
5151TMessage = TypeVar ("TMessage" )
5252
53+
54+ def _consume_control_exception (future : asyncio .Future [StructuredValue ]) -> None :
55+ """Mark a detach control exception retrieved without changing awaiters."""
56+ if not future .cancelled ():
57+ future .exception ()
58+
59+
5360if TYPE_CHECKING :
5461 from cubepi .deferred .types import DeferredStrategy , DeferredToolGroup
5562 from cubepi .providers .fallback import FallbackBoundModel
@@ -287,6 +294,7 @@ async def _composed_resolver(tool_call, *, context, signal=None):
287294 and hasattr (self .checkpointer , "mark_run_complete" )
288295 )
289296 self ._channel = channel
297+ self ._pending_suspension_event : HitlRequest | None = None
290298 # _bind_emit is a _BaseChannel internal, not part of the HitlChannel
291299 # protocol. Third-party channels that only implement the public
292300 # protocol won't have it — skip the wiring instead of crashing.
@@ -384,6 +392,19 @@ async def _dispatch_outcome(self, outcome: RunOutcome | None, run_id: str) -> No
384392 cause = exc ,
385393 ) from exc
386394
395+ async def _emit_suspended_event (self , outcome : RunOutcome ) -> None :
396+ """Publish suspension only after the run transition is committed."""
397+ if outcome != "suspended" :
398+ self ._pending_suspension_event = None
399+ return
400+ pending = self ._pending_suspension_event
401+ if pending is None :
402+ return
403+ self ._pending_suspension_event = None
404+ from cubepi .agent .types import AgentSuspendedEvent
405+
406+ await self ._process_event (AgentSuspendedEvent (pending_request = pending ))
407+
387408 def _validate_hitl_bindings (self , run_id : str | None , * , caller : str ) -> None :
388409 """Reject HITL-bound tools/middleware that disagree with `run_id`.
389410
@@ -518,6 +539,7 @@ async def prompt(
518539 await self ._run_prompt (messages )
519540 except BaseException :
520541 # Spec §3.7: leave active_run_id SET on failure.
542+ self ._pending_suspension_event = None
521543 raise
522544 else :
523545 outcome : RunOutcome = self ._state .last_outcome or "abandoned"
@@ -526,6 +548,7 @@ async def prompt(
526548 # the clear line below is unreachable on the exception path.
527549 await self ._dispatch_outcome (outcome , effective_run_id )
528550 self ._state .active_run_id = None
551+ await self ._emit_suspended_event (outcome )
529552 return effective_run_id
530553
531554 async def fork (
@@ -723,11 +746,13 @@ async def resume(self, *, run_id: str | None = None) -> str:
723746 except BaseException :
724747 # Spec §3.7 parity: leave active_run_id SET on failure so callers
725748 # can observe which run failed.
749+ self ._pending_suspension_event = None
726750 raise
727751 else :
728752 outcome : RunOutcome = self ._state .last_outcome or "abandoned"
729753 await self ._dispatch_outcome (outcome , effective_run_id )
730754 self ._state .active_run_id = None
755+ await self ._emit_suspended_event (outcome )
731756 return effective_run_id
732757
733758 def _build_stream_options (self , signal : asyncio .Event ) -> StreamOptions :
@@ -803,8 +828,6 @@ def _create_context_snapshot(self) -> AgentContext:
803828 )
804829
805830 async def detach (self ) -> None :
806- from cubepi .agent .types import AgentSuspendedEvent
807-
808831 if self ._channel is None :
809832 raise HitlError ("agent has no channel bound" )
810833 pending = self ._channel .pending
@@ -814,11 +837,14 @@ async def detach(self) -> None:
814837 or self ._channel ._future .done ()
815838 ):
816839 return # nothing to detach
817- # Emit the suspended event BEFORE triggering the exception, so listeners
818- # see the real pending payload (codex pass 2 BLOCKING: previous draft
819- # emitted from the loop with pending=None — fundamentally wrong).
820- await self ._process_event (AgentSuspendedEvent (pending_request = pending ))
821- self ._channel ._future .set_exception (HitlDetached ())
840+ # Snapshot the payload before the channel clears its in-memory pending slot,
841+ # then commit the control-flow transition. The owning prompt/resume task
842+ # publishes AgentSuspendedEvent only after it records the suspended outcome
843+ # and clears active_run_id, so observers cannot report an uncommitted pause.
844+ self ._pending_suspension_event = pending
845+ future = self ._channel ._future
846+ future .add_done_callback (_consume_control_exception )
847+ future .set_exception (HitlDetached ())
822848
823849 async def load_pending_hitl_request (self ) -> HitlRequest | None :
824850 if self .checkpointer is None or self .thread_id is None :
@@ -898,14 +924,16 @@ async def respond(
898924 await self ._run_hitl_resume ()
899925 except BaseException :
900926 # Spec §3.7: leave active_run_id SET on raise.
927+ self ._pending_suspension_event = None
901928 raise
902929 else :
903930 # Legacy guard: pending persisted without run_id (older
904931 # save_pending_request callers) cannot drive dispatch.
932+ outcome : RunOutcome = self ._state .last_outcome or "abandoned"
905933 if recovered_run_id is not None :
906- outcome : RunOutcome = self ._state .last_outcome or "abandoned"
907934 await self ._dispatch_outcome (outcome , recovered_run_id )
908935 self ._state .active_run_id = None
936+ await self ._emit_suspended_event (outcome )
909937
910938 async def abort_pending (
911939 self , reason : str = "aborted by host"
@@ -1233,7 +1261,23 @@ async def _process_event(self, event: AgentEvent) -> None:
12331261 await self ._emit_to_listeners (event )
12341262
12351263 async def _emit_to_listeners (self , event : AgentEvent ) -> None :
1236- for listener in self ._listeners :
1237- result = listener (event , self ._active_signal )
1238- if asyncio .iscoroutine (result ):
1239- await result
1264+ cancellation : asyncio .CancelledError | None = None
1265+ first_error : Exception | None = None
1266+ for listener in tuple (self ._listeners ):
1267+ try :
1268+ result = listener (event , self ._active_signal )
1269+ if asyncio .iscoroutine (result ):
1270+ await result
1271+ except asyncio .CancelledError as exc :
1272+ if cancellation is None :
1273+ cancellation = exc
1274+ except Exception as exc :
1275+ # One observer must not hide an event from later observers.
1276+ # Preserve existing error propagation after every listener had
1277+ # the chance to see the same committed state transition.
1278+ if first_error is None :
1279+ first_error = exc
1280+ if cancellation is not None :
1281+ raise cancellation
1282+ if first_error is not None :
1283+ raise first_error
0 commit comments