3636from __future__ import annotations
3737
3838import asyncio
39+ import inspect
3940import logging
4041import uuid
4142
@@ -560,17 +561,26 @@ async def _run_producer(self) -> None:
560561 'Producer[%s]: Execution failed' ,
561562 self ._task_id ,
562563 )
563- # Persist the failure directly instead of relying on the closing
564- # event queue to carry a final status update.
564+ # Persist FAILED (store + push) before finally closes the
565+ # queues (issue #1175). Do not emit a FAILED status event:
566+ # blocking on_message_send would treat that Task as success.
567+ # The producer exception is the stream signal.
565568 if request_context :
566- task = await self ._task_manager .ensure_task_id (
567- self ._task_id ,
568- request_context .context_id or '' ,
569- )
570- if task .status .state not in TERMINAL_TASK_STATES :
571- task .status .state = TaskState .TASK_STATE_FAILED
572- await self ._task_manager .save_task_event (task )
573- self ._task_created .set ()
569+ try :
570+ await self ._task_manager .ensure_task_id (
571+ self ._task_id ,
572+ request_context .context_id or '' ,
573+ )
574+ await self ._persist_and_publish_terminal (
575+ TaskState .TASK_STATE_FAILED ,
576+ publish_to_stream = False ,
577+ )
578+ self ._task_created .set ()
579+ except Exception :
580+ logger .exception (
581+ 'Producer[%s]: Failed to persist FAILED state' ,
582+ self ._task_id ,
583+ )
574584 await self ._event_queue_agent .enqueue_event (cast ('Event' , e ))
575585
576586 finally :
@@ -730,7 +740,10 @@ async def cancel(self, call_context: ServerCallContext) -> Task:
730740 logger .debug (
731741 'Cancel[%s]: Cancelling producer task' , self ._task_id
732742 )
733- self ._producer_task .cancel ()
743+ # Await executor.cancel before cancelling the producer so a
744+ # terminal write can still reach the open subscriber queue
745+ # (#1172 / #1175). Producer cancel stays in finally so a
746+ # BaseException from executor.cancel cannot leak the producer.
734747 try :
735748 await self ._agent_executor .cancel (
736749 request_context , self ._event_queue_agent
@@ -741,13 +754,42 @@ async def cancel(self, call_context: ServerCallContext) -> Task:
741754 )
742755 await self ._mark_task_as_failed (e )
743756 raise
757+ finally :
758+ try :
759+ task = await self ._task_manager .get_task ()
760+ if (
761+ task is not None
762+ and task .status .state not in TERMINAL_TASK_STATES
763+ ):
764+ # Cleanup-only executor.cancel() or a parked
765+ # input-required task leaves no terminal event.
766+ # Write CANCELED and publish it while queues
767+ # are still open so a live subscriber does not
768+ # have to poll.
769+ await self ._persist_and_publish_terminal (
770+ TaskState .TASK_STATE_CANCELED
771+ )
772+ except Exception :
773+ logger .exception (
774+ 'Cancel[%s]: Failed to persist CANCELED state' ,
775+ self ._task_id ,
776+ )
777+ self ._producer_task .cancel ()
744778 else :
745779 logger .debug (
746780 'Cancel[%s]: Task already finished [%s] or producer not started [%s], not cancelling' ,
747781 self ._task_id ,
748782 self ._is_finished .is_set (),
749783 self ._producer_task ,
750784 )
785+ task = await self ._task_manager .get_task ()
786+ if (
787+ task is not None
788+ and task .status .state not in TERMINAL_TASK_STATES
789+ ):
790+ await self ._persist_and_publish_terminal (
791+ TaskState .TASK_STATE_CANCELED
792+ )
751793
752794 await self ._is_finished .wait ()
753795 task = await self ._task_manager .get_task ()
@@ -820,6 +862,54 @@ async def _maybe_cleanup(self) -> None:
820862 logger .debug ('Cleanup[%s]: Triggering cleanup' , self ._task_id )
821863 self ._on_cleanup (self )
822864
865+ async def _persist_and_publish_terminal (
866+ self , state : TaskState , * , publish_to_stream : bool = True
867+ ) -> Task | None :
868+ """Write a terminal state to the store and notify live observers.
869+
870+ Direct ``save_task_event`` after the subscriber queue is closed is
871+ invisible to ``SubscribeToTask`` / ``message/stream`` and to push
872+ (issue #1175). This helper persists a *copy* of the current task
873+ (the shared ``get_task()`` object must not mutate under a reader)
874+ and, when ``publish_to_stream`` is true, emits a
875+ ``TaskStatusUpdateEvent`` to subscribers *before* teardown.
876+
877+ Producer-failure keeps ``publish_to_stream=False``: blocking
878+ ``on_message_send`` treats a FAILED ``Task`` as a successful
879+ terminal result, so the crash must still surface as the
880+ producer exception on the stream. Store and push still get
881+ FAILED before the queues close.
882+ """
883+ task = await self ._task_manager .get_task ()
884+ if task is None :
885+ return None
886+
887+ if task .status .state not in TERMINAL_TASK_STATES :
888+ updated = Task ()
889+ updated .CopyFrom (task )
890+ updated .status .state = state
891+ await self ._task_manager .save_task_event (updated )
892+ task = updated
893+
894+ event = TaskStatusUpdateEvent (
895+ task_id = task .id ,
896+ context_id = task .context_id ,
897+ status = TaskStatus (state = task .status .state ),
898+ )
899+ if publish_to_stream :
900+ updated_task_copy = Task ()
901+ updated_task_copy .CopyFrom (task )
902+ await self ._event_queue_subscribers .enqueue_event (
903+ cast ('Any' , (event , updated_task_copy ))
904+ )
905+ if self ._push_sender and self ._task_id :
906+ notification = self ._push_sender .send_notification (
907+ self ._task_id , event
908+ )
909+ if inspect .isawaitable (notification ):
910+ await notification
911+ return task
912+
823913 async def _mark_task_as_failed (self , exception : Exception ) -> Task | None :
824914 logger .debug ('Marking task %s as failed: %s' , self ._task_id , exception )
825915 task = None
0 commit comments