55from fastapi import Depends
66from pydantic import ValidationError
77
8+ from src .adapters .crud_store .exceptions import ItemDoesNotExist
89from src .adapters .streams .adapter_redis import DRedisStreamRepository
910from src .api .schemas .task_stream_events import TaskStreamEvent
1011from src .config .dependencies import DEnvironmentVariables
1112from src .domain .entities .task_stream_events import (
1213 TaskStreamConnectedEventEntity ,
1314 TaskStreamErrorEventEntity ,
1415 TaskStreamEventEntity ,
16+ TaskStreamTaskUpdatedEventEntity ,
1517 convert_task_stream_event_to_entity ,
1618)
19+ from src .domain .entities .tasks import TERMINAL_TASK_STATUSES
1720from src .domain .services .task_service import DAgentTaskService
1821from src .utils .logging import make_logger
1922from src .utils .stream_metrics import (
@@ -128,10 +131,23 @@ async def stream_task_events(
128131 # congested relay fall behind far enough that those deltas land
129132 # before the snapshot and are never read. Snapshotting first resolves
130133 # to "0-0" (stream is empty until the client sends), so we read from
131- # the beginning.
134+ # the beginning. Reading the cursor before the status check also
135+ # catches a task that goes terminal while we connect.
132136 last_id = await self .stream_repository .get_stream_tail_id (stream_topic )
137+ task = await self .task_service .get_task (id = task_id )
133138 # Send initial connection data
134139 yield f"data: { TaskStreamConnectedEventEntity (type = 'connected' , taskId = task_id ).model_dump_json ()} \n \n "
140+ # Already terminal: replay buffered events and end (late connect).
141+ if task .status in TERMINAL_TASK_STATUSES :
142+ async for _id , data in self .read_messages (
143+ topic = stream_topic , last_id = "0"
144+ ):
145+ yield f"data: { data .model_dump_json ()} \n \n "
146+ await asyncio .sleep (0.02 )
147+ logger .info (
148+ f"Ending SSE stream for task { task_id } : already terminal at connect"
149+ )
150+ return
135151 now = asyncio .get_running_loop ().time ()
136152 # last_message_time drives the keepalive ping and is reset by pings;
137153 # last_event_time tracks only real data pushes (a ping is not a data
@@ -154,9 +170,33 @@ async def stream_task_events(
154170 # client's read fails on each cycle; without backoff this turns into a
155171 # log-ingestion firehose (one failure per client per cycle, ~once/sec).
156172 consecutive_errors = 0
173+ last_status_check = last_message_time
157174 # Application-level control loop
158175 while True :
159176 try :
177+ # Authoritative status recheck on an interval. Runs at the
178+ # TOP of every iteration — even after a read failure/backoff —
179+ # so a terminal task ends even if its event publish was lost
180+ # or Redis reads keep erroring.
181+ current_time = asyncio .get_running_loop ().time ()
182+ if current_time - last_status_check >= ping_interval :
183+ last_status_check = current_time
184+ try :
185+ task = await self .task_service .get_task (id = task_id )
186+ except ItemDoesNotExist :
187+ # Row permanently gone (e.g. retention) — end, don't retry.
188+ logger .info (
189+ f"Ending SSE stream for task { task_id } : "
190+ "task no longer exists"
191+ )
192+ return
193+ if task .status in TERMINAL_TASK_STATUSES :
194+ logger .info (
195+ f"Ending SSE stream for task { task_id } : "
196+ "terminal on status recheck"
197+ )
198+ return
199+
160200 # Process yielded messages one by one
161201 message_generator = self .read_messages (
162202 topic = stream_topic , last_id = last_id
@@ -173,29 +213,38 @@ async def stream_task_events(
173213 last_message_time = now
174214 last_event_time = now
175215 stalled = False
216+ # Terminal event is the last one — end here.
217+ if (
218+ isinstance (data , TaskStreamTaskUpdatedEventEntity )
219+ and data .task is not None
220+ and data .task .status in TERMINAL_TASK_STATUSES
221+ ):
222+ logger .info (
223+ f"Ending SSE stream for task { task_id } : received "
224+ "a terminal task_updated event"
225+ )
226+ return
176227 await asyncio .sleep (0.02 )
177228
178229 # A read cycle completed without raising — the stream is
179230 # healthy again, so reset the backoff/error counter.
180231 consecutive_errors = 0
181232
182- # If we didn't get any messages, add a small pause
183- # to prevent tight loops and send keepalive ping if needed
233+ # Idle: send keepalive ping so proxies don't reap us. Use a
234+ # fresh timestamp — the read above blocks up to timeout_ms, so
235+ # the loop-top current_time would be stale for ping timing.
184236 if message_count == 0 :
185- current_time = asyncio .get_running_loop ().time ()
237+ now = asyncio .get_running_loop ().time ()
186238 # No data event pushed for the stall window: count the
187239 # onset once. Keepalive pings deliberately do not reset
188240 # last_event_time, so a persistently quiet stream is
189241 # visible even though the connection stays alive.
190- if (
191- current_time - last_event_time >= stall_threshold
192- and not stalled
193- ):
242+ if now - last_event_time >= stall_threshold and not stalled :
194243 stalled = True
195244 record_stream_stall ()
196- if current_time - last_message_time >= ping_interval :
245+ if now - last_message_time >= ping_interval :
197246 yield ":ping\n \n "
198- last_message_time = current_time
247+ last_message_time = now
199248 await asyncio .sleep (0.1 )
200249 else :
201250 # Small pause between batches
@@ -238,6 +287,7 @@ async def stream_task_events(
238287 )
239288 yield f"data: { TaskStreamErrorEventEntity (type = 'error' , message = str (e )).model_dump_json ()} \n \n "
240289 finally :
290+ # Don't delete the shared topic; the TTL reclaims it.
241291 logger .info (f"SSE stream for task { task_id } has ended" )
242292 # Only close what we actually opened, so the active gauge is never
243293 # decremented for a stream that never incremented it.
@@ -246,7 +296,6 @@ async def stream_task_events(
246296 outcome ,
247297 asyncio .get_running_loop ().time () - stream_start_time ,
248298 )
249- await self .cleanup_stream (stream_topic )
250299
251300
252301DStreamsUseCase = Annotated [StreamsUseCase , Depends (StreamsUseCase )]
0 commit comments