{"repo":"NousResearch/hermes-agent","public_id":"24451","display_label":"SSE stream termination race condition fixes","summary":"These issues and pull requests address a race condition where SSE streams fail to receive a completion signal when an agent task finishes. Multiple fix approaches involve sending a sentinel or waking the SSE writer upon task completion.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":24578,"merged_at":"2026-05-12T22:04:55+00:00"},"resolution":{"state":"resolved","by":"pr-24578"},"freshness":{"state":"verified","last_verified_at":"2026-05-12T22:04:55+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":24451,"pr_number":24578,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-12T22:04:55+00:00"}],"members":[{"kind":"issue","number":24451,"title":"SSE streaming: response.completed never sent due to race condition with agent_task.done()","url":null,"state":"closed"},{"kind":"pr","number":24464,"title":"fix(api-server): wake SSE writer immediately on agent_task completion (#24451)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24578","number":24578}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24554,"title":"fix(api_server): push None sentinel via done callback to end SSE loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24464","number":24464},{"kind":"pr","value":"#24451","number":24451}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":24578,"title":"fix(gateway): enqueue SSE EOS sentinel on task completion","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","tests/gateway/test_api_server.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -1168,6 +1168,9 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul\n                 agent_ref=agent_ref,\n                 gateway_session_key=gateway_session_key,\n             ))\n+            # Ensure SSE drain loops can terminate without relying on polling\n+            # agent_task.done(), which can race with queue timeout checks.\n+            agent_task.add_done_callback(lambda _fut: _stream_q.put(None))\n \n             return await self._write_sse_chat_completion(\n                 request, completion_id, model_name, created, _stream_q,","@@ -2197,6 +2200,9 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul\n                 agent_ref=agent_ref,\n                 gateway_session_key=gateway_session_key,\n             ))\n+            # Ensure SSE drain loops can terminate without relying on polling\n+            # agent_task.done(), which can race with queue timeout checks.\n+            agent_task.add_done_callback(lambda _fut: _stream_q.put(None))\n \n             response_id = f\"resp_{uuid.uuid4().hex[:28]}\"\n             model_name = body.get(\"model\", self._model_name)"]},{"file":"tests/gateway/test_api_server.py","hunks":["@@ -681,6 +681,56 @@ async def _mock_run_agent(**kwargs):\n                 assert \"[DONE]\" in body\n                 assert \"Hello!\" in body\n \n+    @pytest.mark.asyncio\n+    async def test_stream_task_done_callback_enqueues_eos_for_chat_completions(self, adapter):\n+        \"\"\"Regression guard for #24451: completion callback must signal SSE EOS.\"\"\"\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            class _FakeTask:\n+                def __init__(self):\n+                    self.callbacks = []\n+\n+                def add_done_callback(self, cb):\n+                    self.callbacks.append(cb)\n+\n+            fake_task = _FakeTask()\n+\n+            def _fake_ensure_future(coro):\n+                # We short-circuit task scheduling in this unit test.\n+                coro.close()\n+                return fake_task\n+\n+            with (\n+                patch.object(\n+                    adapter,\n+                    \"_run_agent\",\n+                    new=AsyncMock(\n+                        return_value=(\n+                            {\"final_response\": \"ok\", \"messages\": [], \"api_calls\": 1},\n+                            {\"input_tokens\": 1, \"output_tokens\": 1, \"total_tokens\": 2},\n+                        )\n+                    ),\n+                ),\n+                patch(\"gateway.platforms.api_server.asyncio.ensure_future\", side_effect=_fake_ensure_future),\n+                patch.object(adapter, \"_write_sse_chat_completion\", new_callable=AsyncMock) as mock_write_sse,\n+            ):\n+                mock_write_sse.return_value = web.Response(status=200, text=\"ok\")\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    json={\n+                        \"model\": \"test\",\n+                        \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}],\n+                        \"stream\": True,\n+                    },\n+                )\n+                assert resp.status == 200\n+\n+            assert len(fake_task.callbacks) == 1\n+            stream_q = mock_write_sse.call_args.args[4]\n+            assert stream_q.empty()\n+            fake_task.callbacks[0](fake_task)\n+            assert stream_q.get_nowait() is None\n+\n     @pytest.mark.asyncio\n     async def test_stream_sends_keepalive_during_quiet_tool_gap(self, adapter):\n         \"\"\"Idle SSE streams should send keepalive comments while tools run silently.\"\"\"","@@ -1676,6 +1726,52 @@ async def _mock_run_agent(**kwargs):\n                 assert \"Hello\" in body\n                 assert \" world\" in body\n \n+    @pytest.mark.asyncio\n+    async def test_stream_task_done_callback_enqueues_eos_for_responses(self, adapter):\n+        \"\"\"Regression guard for #24451 on /v1/responses streaming path.\"\"\"\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            class _FakeTask:\n+                def __init__(self):\n+                    self.callbacks = []\n+\n+                def add_done_callback(self, cb):\n+                    self.callbacks.append(cb)\n+\n+            fake_task = _FakeTask()\n+\n+            def _fake_ensure_future(coro):\n+                # We short-circuit task scheduling in this unit test.\n+                coro.close()\n+                return fake_task\n+\n+            with (\n+                patch.object(\n+                    adapter,\n+                    \"_run_agent\",\n+                    new=AsyncMock(\n+                        return_value=(\n+                            {\"final_response\": \"ok\", \"messages\": [], \"api_calls\": 1},\n+                            {\"input_tokens\": 1, \"output_tokens\": 1, \"total_tokens\": 2},\n+                        )\n+                    ),\n+                ),\n+                patch(\"gateway.platforms.api_server.asyncio.ensure_future\", side_effect=_fake_ensure_future),\n+                patch.object(adapter, \"_write_sse_responses\", new_callable=AsyncMock) as mock_write_sse,\n+            ):\n+                mock_write_sse.return_value = web.Response(status=200, text=\"ok\")\n+                resp = await cli.post(\n+                    \"/v1/responses\",\n+                    json={\"model\": \"hermes-agent\", \"input\": \"hi\", \"stream\": True},\n+                )\n+                assert resp.status == 200\n+\n+            assert len(fake_task.callbacks) == 1\n+            stream_q = mock_write_sse.call_args.kwargs[\"stream_q\"]\n+            assert stream_q.empty()\n+            fake_task.callbacks[0](fake_task)\n+            assert stream_q.get_nowait() is None\n+\n     @pytest.mark.asyncio\n     async def test_stream_emits_function_call_and_output_items(self, adapter):\n         app = _create_app(adapter)","@@ -3061,4 +3157,3 @@ async def test_capabilities_advertises_session_key_header(self, adapter):\n             assert resp.status == 200\n             data = await resp.json()\n             assert data[\"features\"][\"session_key_header\"] == \"X-Hermes-Session-Key\"\n-\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}