{"repo":"NousResearch/hermes-agent","public_id":"10156","display_label":"poll() must not mark completions as consumed","summary":"Multiple fixes and a regression report addressing poll() incorrectly setting a consumed flag that silently suppresses notify_on_complete watcher notifications. The core issue is that poll() should be read-only and not interfere with completion signaling.","subsystem":"agent","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50224},"resolution":{"state":"resolved","by":"pr-50224"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T18:11:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2},"fix_edges":[{"issue_number":10156,"pr_number":50224,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":10156,"title":"Regression: poll() marking _completion_consumed silently suppresses watcher notification","url":null,"state":"closed"},{"kind":"pr","number":10158,"title":"fix: keep poll read-only for notify_on_complete watcher","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6f5f58e34","number":0},{"kind":"pr","value":"#50224","number":50224},{"kind":"pr","value":"#8228","number":8228},{"kind":"pr","value":"#10156","number":10156}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":13773,"title":"fix(process): preserve completion notification after poll/log/wait on long background tasks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6f5f58e34","number":0},{"kind":"commit","value":"41ba90f81","number":0},{"kind":"pr","value":"#10156","number":10156}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21903,"title":"fix(process_registry): poll() must not mark completion as consumed","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":26616,"title":"fix(process_registry): poll() must not mark completion as consumed (#10156)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10158","number":10158},{"kind":"pr","value":"#21903","number":21903}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":26659,"title":"fix(process_registry): poll() must not mark completion as consumed (#10156)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10158","number":10158},{"kind":"pr","value":"#26616","number":26616},{"kind":"pr","value":"#21903","number":21903}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":31750,"title":"fix(process_registry): keep poll() read-only — do not mark completions consumed","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10158","number":10158}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":31756,"title":"fix(process_registry): keep poll() read-only — do not mark completions consumed","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10158","number":10158}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":35552,"title":"fix(process_registry): keep poll() read-only — do not mark completions consumed","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10158","number":10158},{"kind":"pr","value":"#10158","number":10158}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":50224,"title":"fix(process): keep poll read-only so notify_on_complete watcher fires (#10156)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/gateway/test_internal_event_bypass_pairing.py","tests/tools/test_notify_on_complete.py","tools/process_registry.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -13136,7 +13136,9 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n \n             if session.exited:\n                 # --- Agent-triggered completion: inject synthetic message ---\n-                # Skip if the agent already consumed the result via wait/poll/log\n+                # Skip if the agent already consumed the result via wait/log.\n+                # poll() is read-only and intentionally does NOT mark consumed\n+                # (#10156) — a status check must not suppress this delivery turn.\n                 from tools.process_registry import format_process_notification, process_registry as _pr_check\n                 if agent_notify and not _pr_check.is_completion_consumed(session_id):\n                     from tools.ansi_strip import strip_ansi"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"de1tydev\",  # PR #10158 salvage (poll read-only for notify_on_complete watcher; #10156)\n     \"[redacted:email]\": \"szzhoujiarui-sketch\",  # cron model.default salvage co-author (#45550)\n     \"[redacted:email]\": \"rayjun\",  # cron model.default salvage co-author (#43952)\n     \"[redacted:email]\": \"sweetcornna\",  # cron ticker-liveness salvage co-author (#33849)"]},{"file":"tests/gateway/test_internal_event_bypass_pairing.py","hunks":["@@ -17,6 +17,7 @@\n from gateway.platforms.base import MessageEvent\n from gateway.run import GatewayRunner\n from gateway.session import SessionSource\n+from tools.process_registry import ProcessRegistry, ProcessSession\n \n \n # ---------------------------------------------------------------------------","@@ -99,6 +100,46 @@ async def _instant_sleep(*_a, **_kw):\n     assert event.internal is True, \"Synthetic completion event must be marked internal\"\n \n \n[redacted:email]\n+async def test_poll_does_not_suppress_notify_on_complete_watcher(monkeypatch, tmp_path):\n+    \"\"\"Regression: polling an exited process must not suppress watcher injection.\"\"\"\n+    import tools.process_registry as pr_module\n+\n+    registry = ProcessRegistry()\n+    session = ProcessSession(\n+        id=\"proc_polled_completion\",\n+        command=\"echo done\",\n+        output_buffer=\"done\\n\",\n+        exited=True,\n+        exit_code=0,\n+        notify_on_complete=True,\n+    )\n+    registry._finished[session.id] = session\n+\n+    poll_result = registry.poll(session.id)\n+    assert poll_result[\"status\"] == \"exited\"\n+    assert not registry.is_completion_consumed(session.id)\n+\n+    monkeypatch.setattr(pr_module, \"process_registry\", registry)\n+\n+    async def _instant_sleep(*_a, **_kw):\n+        pass\n+    monkeypatch.setattr(asyncio, \"sleep\", _instant_sleep)\n+\n+    runner = _build_runner(monkeypatch, tmp_path)\n+    adapter = runner.adapters[Platform.DISCORD]\n+\n+    watcher = _watcher_dict_with_notify()\n+    watcher[\"session_id\"] = session.id\n+\n+    await runner._run_process_watcher(watcher)\n+\n+    assert adapter.handle_message.await_count == 1\n+    event = adapter.handle_message.await_args.args[0]\n+    assert session.id in event.text\n+    assert event.internal is True\n+\n+\n @pytest.mark.asyncio\n async def test_internal_event_bypasses_authorization(monkeypatch, tmp_path):\n     \"\"\"An internal event should skip _is_user_authorized entirely.\"\"\""]},{"file":"tests/tools/test_notify_on_complete.py","hunks":["@@ -325,7 +325,7 @@ def test_notify_on_complete_blocked_in_sandbox(self):\n # =========================================================================\n \n class TestCompletionConsumed:\n-    \"\"\"Test that wait/poll/log suppress redundant completion notifications.\"\"\"\n+    \"\"\"Test that wait/log consume completion notifications while poll stays read-only.\"\"\"\n \n     def test_wait_marks_completion_consumed(self, registry):\n         \"\"\"wait() returning exited status marks session as consumed.\"\"\"","@@ -347,8 +347,8 @@ def test_wait_marks_completion_consumed(self, registry):\n         # Now the completion is marked as consumed\n         assert registry.is_completion_consumed(\"proc_wait\")\n \n-    def test_poll_marks_completion_consumed(self, registry):\n-        \"\"\"poll() returning exited status marks session as consumed.\"\"\"\n+    def test_poll_does_not_mark_completion_consumed(self, registry):\n+        \"\"\"poll() is a read-only status check and must not suppress notify_on_complete.\"\"\"\n         s = _make_session(sid=\"proc_poll\", notify_on_complete=True, output=\"done\")\n         s.exited = True\n         s.exit_code = 0","@@ -356,7 +356,7 @@ def test_poll_marks_completion_consumed(self, registry):\n \n         result = registry.poll(\"proc_poll\")\n         assert result[\"status\"] == \"exited\"\n-        assert registry.is_completion_consumed(\"proc_poll\")\n+        assert not registry.is_completion_consumed(\"proc_poll\")\n \n     def test_log_marks_completion_consumed(self, registry):\n         \"\"\"read_log() on exited session marks as consumed.\"\"\"","@@ -378,6 +378,72 @@ def test_running_process_not_consumed(self, registry):\n         assert result[\"status\"] == \"running\"\n         assert not registry.is_completion_consumed(\"proc_running\")\n \n+    def test_poll_marks_poll_observed_for_cli_drain(self, registry):\n+        \"\"\"poll() on an exited process records _poll_observed so the CLI drain\n+        dedups (the agent already saw the exit inline) without marking the\n+        session _completion_consumed (which would suppress the gateway watcher).\"\"\"\n+        s = _make_session(sid=\"proc_pobs\", notify_on_complete=True, output=\"done\")\n+        s.exited = True\n+        s.exit_code = 0\n+        registry._running[s.id] = s\n+        with patch.object(registry, \"_write_checkpoint\"):\n+            registry._move_to_finished(s)\n+\n+        # Completion is queued, nothing consumed/observed yet.\n+        assert not registry.completion_queue.empty()\n+        assert \"proc_pobs\" not in registry._poll_observed\n+        assert not registry.is_completion_consumed(\"proc_pobs\")\n+\n+        # Agent polls inline — read-only, so NOT _completion_consumed, but the\n+        # exit was observed so the CLI drain must skip the queued completion.\n+        assert registry.poll(\"proc_pobs\")[\"status\"] == \"exited\"\n+        assert \"proc_pobs\" in registry._poll_observed\n+        assert not registry.is_completion_consumed(\"proc_pobs\")\n+\n+        # CLI drain skips it → no duplicate [SYSTEM: ...] injection (#8228).\n+        drained = registry.drain_notifications()\n+        assert drained == []\n+\n+    def test_poll_observed_does_not_suppress_gateway_watcher(self, registry):\n+        \"\"\"The gateway/tui watcher gate (is_completion_consumed) must stay False\n+        after a read-only poll, so the autonomous delivery turn still fires\n+        even though the CLI drain was deduped (#10156).\"\"\"\n+        s = _make_session(sid=\"proc_gw\", notify_on_complete=True, output=\"done\")\n+        s.exited = True\n+        s.exit_code = 0\n+        registry._finished[s.id] = s\n+\n+        registry.poll(\"proc_gw\")\n+        # CLI-side dedup signal present...\n+        assert \"proc_gw\" in registry._poll_observed\n+        # ...but the gateway watcher gate is untouched, so it still delivers.\n+        assert not registry.is_completion_consumed(\"proc_gw\")\n+\n+    def test_running_poll_does_not_mark_poll_observed(self, registry):\n+        \"\"\"poll() on a still-running process must not record _poll_observed.\"\"\"\n+        s = _make_session(sid=\"proc_run2\", notify_on_complete=True, output=\"partial\")\n+        registry._running[s.id] = s\n+\n+        registry.poll(\"proc_run2\")\n+        assert \"proc_run2\" not in registry._poll_observed\n+\n+    def test_wait_and_log_still_skip_cli_drain(self, registry):\n+        \"\"\"wait()/read_log() consume the output, so the CLI drain skips their\n+        completions via _completion_consumed (the original #8228 contract).\"\"\"\n+        for sid, action in ((\"proc_w\", \"wait\"), (\"proc_l\", \"log\")):\n+            s = _make_session(sid=sid, notify_on_complete=True, output=\"done\")\n+            s.exited = True\n+            s.exit_code = 0\n+            registry._running[s.id] = s\n+            with patch.object(registry, \"_write_checkpoint\"):\n+                registry._move_to_finished(s)\n+            if action == \"wait\":\n+                registry.wait(sid, timeout=1)\n+            else:\n+                registry.read_log(sid)\n+            assert registry.is_completion_consumed(sid)\n+        assert registry.drain_notifications() == []\n+\n \n # ---------------------------------------------------------------------------\n # Silent-background-process hint"]},{"file":"tools/process_registry.py","hunks":["@@ -171,9 +171,21 @@ def __init__(self):\n         self.completion_queue: _queue_mod.Queue = _queue_mod.Queue()\n \n         # Track sessions whose completion was already consumed by the agent\n-        # via wait/poll/log.  Drain loops skip notifications for these.\n+        # via wait/log.  Drain loops AND gateway/tui watchers skip notifications\n+        # for these — a blocking wait() or a full read_log() means the agent\n+        # has the output in hand and is acting on it this turn.\n         self._completion_consumed: set = set()\n \n+        # Track sessions the agent merely *observed* exited via poll().  poll()\n+        # is a read-only status check, so it does NOT mark _completion_consumed\n+        # (that would let a status check suppress the gateway/tui watcher's\n+        # autonomous delivery turn — #10156).  But on the CLI the poll result\n+        # is returned inline in the same turn, so the idle/post-turn drain must\n+        # still skip the queued completion to avoid a duplicate [SYSTEM: ...]\n+        # injection (the bug #8228 originally fixed).  drain_notifications()\n+        # consults this set; the gateway/tui watchers deliberately do NOT.\n+        self._poll_observed: set = set()\n+\n         # Global watch-match circuit breaker — across all sessions.\n         # Prevents sibling processes from collectively flooding the user even\n         # when each stays under its own per-session cap.","@@ -908,14 +920,28 @@ def _move_to_finished(self, session: ProcessSession):\n     # ----- Query Methods -----\n \n     def is_completion_consumed(self, session_id: str) -> bool:\n-        \"\"\"Check if a completion notification was already consumed via wait/poll/log.\"\"\"\n+        \"\"\"Check if a completion notification was already consumed via wait/log.\"\"\"\n         return session_id in self._completion_consumed\n \n+    def _drain_should_skip(self, session_id: str) -> bool:\n+        \"\"\"Whether the CLI drain should skip a completion event for this session.\n+\n+        Skips when the agent has either truly consumed the output (wait/log →\n+        ``_completion_consumed``) or observed the exit inline via poll()\n+        (``_poll_observed``).  In both cases the CLI agent already has the\n+        result this turn, so injecting a [SYSTEM: ...] completion would be a\n+        duplicate (#8228).  The gateway/tui watchers do NOT use this — they\n+        check only ``is_completion_consumed`` so a read-only poll never\n+        suppresses their autonomous delivery turn (#10156).\n+        \"\"\"\n+        return session_id in self._completion_consumed or session_id in self._poll_observed\n+\n     def drain_notifications(self) -> \"list[tuple[dict, str]]\":\n         \"\"\"Pop all pending notification events and return formatted pairs.\n \n         Returns a list of (raw_event, formatted_text) tuples.\n-        Skips completion events that were already consumed via wait/poll/log.\n+        Skips completion events the agent already consumed via wait/log or\n+        observed inline via poll() (see ``_drain_should_skip``).\n         \"\"\"\n         results = []\n         while not self.completion_queue.empty():","@@ -924,7 +950,7 @@ def drain_notifications(self) -> \"list[tuple[dict, str]]\":\n             except Exception:\n                 break\n             _evt_sid = evt.get(\"session_id\", \"\")\n-            if evt.get(\"type\") == \"completion\" and self.is_completion_consumed(_evt_sid):\n+            if evt.get(\"type\") == \"completion\" and self._drain_should_skip(_evt_sid):\n                 continue\n             text = format_process_notification(evt)\n             if text:","@@ -1038,7 +1064,17 @@ def poll(self, session_id: str) -> dict:\n             result[\"exit_code\"] = session.exit_code\n             result[\"completion_reason\"] = session.completion_reason\n             result[\"termination_source\"] = session.termination_source\n-            self._completion_consumed.add(session_id)\n+            # NOTE: poll() is a read-only status query and deliberately does\n+            # NOT mark the session _completion_consumed. wait()/read_log()\n+            # represent actual output consumption and do mark it. Marking\n+            # consumed here would let a status check silently suppress the\n+            # notify_on_complete watcher's autonomous delivery turn (#10156).\n+            #\n+            # We DO record it in _poll_observed so the CLI's inline drain still\n+            # dedups (the agent already saw the exit in this turn's poll result)\n+            # without affecting the gateway/tui watchers, which only consult\n+            # _completion_consumed.\n+            self._poll_observed.add(session_id)\n         if session.detached:\n             result[\"detached\"] = True\n             result[\"note\"] = \"Process recovered after restart -- output history unavailable\"","@@ -1394,6 +1430,7 @@ def _prune_if_needed(self):\n         for sid in expired:\n             del self._finished[sid]\n             self._completion_consumed.discard(sid)\n+            self._poll_observed.discard(sid)\n \n         # If still over limit, remove oldest finished\n         total = len(self._running) + len(self._finished)","@@ -1401,14 +1438,19 @@ def _prune_if_needed(self):\n             oldest_id = min(self._finished, key=lambda sid: self._finished[sid].started_at)\n             del self._finished[oldest_id]\n             self._completion_consumed.discard(oldest_id)\n+            self._poll_observed.discard(oldest_id)\n \n-        # Drop any _completion_consumed entries whose sessions are no longer\n-        # tracked at all — belt-and-suspenders against module-lifetime growth\n-        # on process-registry lookup paths that don't reach the dict prunes.\n+        # Drop any _completion_consumed / _poll_observed entries whose sessions\n+        # are no longer tracked at all — belt-and-suspenders against\n+        # module-lifetime growth on registry lookup paths that don't reach the\n+        # dict prunes.\n         tracked = self._running.keys() | self._finished.keys()\n         stale = self._completion_consumed - tracked\n         if stale:\n             self._completion_consumed -= stale\n+        stale_polls = self._poll_observed - tracked\n+        if stale_polls:\n+            self._poll_observed -= stale_polls\n \n     # ----- Checkpoint (crash recovery) -----\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}