@@ -744,3 +744,110 @@ async def brief_reader():
744744 )
745745
746746 print ("✅ Shared stream survives a single subscriber disconnecting" )
747+
748+ async def test_stream_ends_on_late_connect_to_terminal_task (
749+ self , test_agent_and_task , tasks_use_case , streams_use_case
750+ ):
751+ """
752+ Fallback termination path: a viewer that connects *after* the task has
753+ already reached a terminal state never receives a terminal task_updated
754+ event (it is behind the snapshot cursor), so the event-driven exit can't
755+ fire. The keepalive-cadence fallback must still end the stream instead of
756+ blocking on the topic forever.
757+ """
758+ # Shrink the keepalive interval so the fallback check runs within ~1s
759+ # instead of the production 15s.
760+ streams_use_case .environment_variables .SSE_KEEPALIVE_PING_INTERVAL = 1
761+
762+ _agent , task = test_agent_and_task
763+
764+ # Finish the task BEFORE anyone subscribes. The terminal task_updated is
765+ # XADDed to the stream now, so a later subscriber snapshots past it.
766+ completed = await tasks_use_case .complete_task (id = task .id )
767+ assert completed .status == TaskStatus .COMPLETED
768+
769+ async def drain_until_end ():
770+ # Returns normally only if the generator terminates by itself.
771+ async for _event_data in streams_use_case .stream_task_events (
772+ task_id = task .id
773+ ):
774+ pass
775+
776+ reader_task = asyncio .create_task (drain_until_end ())
777+
778+ # With no terminal event to react to, only the fallback can end it.
779+ try :
780+ await asyncio .wait_for (reader_task , timeout = 10 )
781+ except TimeoutError as err :
782+ reader_task .cancel ()
783+ try :
784+ await reader_task
785+ except asyncio .CancelledError :
786+ pass
787+ raise AssertionError (
788+ "SSE stream to an already-terminal task did not self-close — the "
789+ "fallback termination check is not firing, so late-connect "
790+ "subscriptions leak as zombies."
791+ ) from err
792+
793+ print ("✅ Stream self-closes when connecting to an already-terminal task" )
794+
795+ async def test_running_task_stream_survives_reclaimed_key (
796+ self , test_agent_and_task , streams_use_case
797+ ):
798+ """
799+ Regression for the review finding "expired key ends live stream": a
800+ still-RUNNING task whose idle stream key is reclaimed by the sliding TTL
801+ must NOT be closed. A later XADD recreates the key, so the fallback must
802+ only treat a vanished topic as terminal when the task status is unknown,
803+ never when the task is confirmed non-terminal.
804+ """
805+ from src .utils .stream_topics import get_task_event_stream_topic
806+
807+ # Fast fallback cadence so the vanished-key check gets a chance to (wrongly)
808+ # fire within the test window if the regression is present.
809+ streams_use_case .environment_variables .SSE_KEEPALIVE_PING_INTERVAL = 1
810+
811+ _agent , task = test_agent_and_task # created in RUNNING state
812+ stream_topic = get_task_event_stream_topic (task_id = task .id )
813+ repo = streams_use_case .stream_repository
814+
815+ # Seed the topic so it has existed (arms the vanished-topic fallback), then
816+ # subscribe.
817+ await repo .send_data (stream_topic , {"type" : "error" , "message" : "seed" })
818+ assert await repo .stream_exists (stream_topic ), "precondition: topic exists"
819+
820+ async def reader ():
821+ try :
822+ async for _event_data in streams_use_case .stream_task_events (
823+ task_id = task .id
824+ ):
825+ pass
826+ except asyncio .CancelledError :
827+ pass
828+
829+ reader_task = asyncio .create_task (reader ())
830+
831+ # Let the reader connect and go idle, then simulate the sliding TTL
832+ # reclaiming the key while the task is still RUNNING.
833+ await asyncio .sleep (0.5 )
834+ await repo .cleanup_stream (stream_topic )
835+ assert not await repo .stream_exists (stream_topic ), "precondition: key reclaimed"
836+
837+ # Give the fallback several cycles to (wrongly) close the stream.
838+ await asyncio .sleep (4 )
839+
840+ try :
841+ assert not reader_task .done (), (
842+ "SSE stream for a still-RUNNING task was closed after its idle key "
843+ "was reclaimed — the fallback must not treat a reclaimed key as "
844+ "terminal when the task is confirmed non-terminal."
845+ )
846+ finally :
847+ reader_task .cancel ()
848+ try :
849+ await reader_task
850+ except asyncio .CancelledError :
851+ pass
852+
853+ print ("✅ Running task's stream stays open when its idle key is reclaimed" )
0 commit comments