@@ -103,26 +103,12 @@ async def stream_task_events(
103103 task_id = task .id
104104
105105 stream_topic = get_task_event_stream_topic (task_id = task_id )
106- # Snapshot the read cursor BEFORE reading task status (and before yielding
107- # "connected"). Ordering is load-bearing for two reasons:
108- # 1. A terminal transition writes the DB *before* it XADDs its
109- # task_updated event. Snapshotting the tail first means that if the
110- # status read below still says non-terminal, the terminal event (if
111- # one is racing in) is guaranteed to land *after* this cursor, so the
112- # live loop will read it — no missed-terminal zombie.
113- # 2. "connected" is the client's cue to send its message, which makes
114- # the agent start XADD-ing deltas; snapshotting after that yield
115- # could let the first deltas advance the tail and be skipped.
116- # Resolves to "0-0" (from the beginning) when the stream is empty.
106+ # Cursor before status read: catches a racing terminal event.
117107 last_id = await self .stream_repository .get_stream_tail_id (stream_topic )
118- # Authoritative connect-time check. If the task is ALREADY terminal, any
119- # terminal event sits behind the live tail and the read loop below would
120- # never surface it — so replay whatever is buffered and end here. This
121- # handles the late-connect case deterministically (no polling needed);
122- # the in-loop check below handles a task that finishes while streaming.
123108 task = await self .task_service .get_task (id = task_id )
124109 # Send initial connection data
125110 yield f"data: { TaskStreamConnectedEventEntity (type = 'connected' , taskId = task_id ).model_dump_json ()} \n \n "
111+ # Already terminal: replay buffered events and end (late connect).
126112 if task .status in TERMINAL_TASK_STATUSES :
127113 async for _id , data in self .read_messages (topic = stream_topic , last_id = "0" ):
128114 yield f"data: { data .model_dump_json ()} \n \n "
@@ -158,8 +144,7 @@ async def stream_task_events(
158144 data_str = f"data: { data .model_dump_json ()} \n \n "
159145 yield data_str
160146 last_message_time = asyncio .get_running_loop ().time ()
161- # A terminal task_updated is always the last event
162- # produced, so nothing follows it — end the stream here.
147+ # Terminal event is the last one — end here.
163148 if (
164149 isinstance (data , TaskStreamTaskUpdatedEventEntity )
165150 and data .task is not None
@@ -176,8 +161,7 @@ async def stream_task_events(
176161 # healthy again, so reset the backoff/error counter.
177162 consecutive_errors = 0
178163
179- # No messages this cycle: send a keepalive ping on the
180- # configured interval so proxies don't reap an idle stream.
164+ # Idle: send keepalive ping so proxies don't reap us.
181165 if message_count == 0 :
182166 current_time = asyncio .get_running_loop ().time ()
183167 if current_time - last_message_time >= ping_interval :
@@ -224,9 +208,7 @@ async def stream_task_events(
224208 )
225209 yield f"data: { TaskStreamErrorEventEntity (type = 'error' , message = str (e )).model_dump_json ()} \n \n "
226210 finally :
227- # No cleanup_stream here: the topic is shared by all viewers of the
228- # task, so deleting it on one subscriber's exit would break the
229- # others. The sliding TTL reclaims it instead.
211+ # Don't delete the shared topic; the TTL reclaims it.
230212 logger .info (f"SSE stream for task { task_id } has ended" )
231213
232214
0 commit comments