{"repo":"NousResearch/hermes-agent","public_id":"32762","display_label":"clarify/secret prompt timeout and expiry handling","summary":"These issues and fixes address how clarify and secret prompts behave when they expire or receive late responses, including hard errors returned to clients, uncancellable overlays in the TUI, and lost resolver state across Telegram gateway restarts.","subsystem":"dashboard","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":3,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54056},"resolution":{"state":"resolved","by":"pr-54056,pr-61885,pr-69773"},"freshness":{"state":"verified","last_verified_at":"2026-07-23T04:24:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18666666666666665},"fix_edges":[{"issue_number":32762,"pr_number":54056,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":56558,"pr_number":69773,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61748,"pr_number":61885,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":32762,"title":"gateway: Telegram clarify-button resolver loses state on SIGTERM restart and 600s timeout","url":null,"state":"closed"},{"kind":"issue","number":56558,"title":"gateway: clarify.respond to an expired prompt returns hard 4009 'no pending answer request', leaking to client and wedging the chat","url":null,"state":"closed"},{"kind":"issue","number":61748,"title":"TUI secret.respond zombie overlay: 4009 'no pending value request' leaves secret prompt uncancellable","url":null,"state":"closed"},{"kind":"pr","number":33850,"title":"fix(telegram): persist clarify-button resolver state across restarts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32878","number":32878},{"kind":"pr","value":"#33073","number":33073},{"kind":"pr","value":"#32878","number":32878},{"kind":"pr","value":"#33073","number":33073},{"kind":"pr","value":"#32878","number":32878},{"kind":"pr","value":"#33073","number":33073},{"kind":"pr","value":"#32878","number":32878},{"kind":"pr","value":"#33073","number":33073}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54056,"title":"fix(clarify): notify on expired Telegram tap + raise default clarify_timeout (#32762)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","infographic/clarify-expiry-32762/infographic.png","plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_clarify_buttons.py","tests/tools/test_clarify_gateway.py","tools/clarify_gateway.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -1012,7 +1012,13 @@ def _ensure_hermes_home_managed(home: Path):\n         # unblocks with \"[user did not respond within Xm]\" so it can adapt\n         # rather than pinning the running-agent guard forever.  CLI clarify\n         # blocks indefinitely (input() is synchronous) and ignores this.\n-        \"clarify_timeout\": 600,\n+        # Default 3600 (1h): real users step away (meetings, AFK) and the\n+        # old 600s default evicted the entry mid-think, so a later button\n+        # tap landed on a dead entry (#32762).  Tradeoff: a higher value\n+        # holds the gateway's running-agent guard longer for a genuinely\n+        # abandoned prompt — lower it if a single session must free up the\n+        # guard sooner.\n+        \"clarify_timeout\": 3600,\n         # Periodic \"still working\" notification interval (seconds).\n         # Sends a status message every N seconds so the user knows the\n         # agent hasn't died during long tasks.  0 = disable notifications."]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -4292,6 +4292,31 @@ def get_label(slug):\n             # Catch-all (e.g. page counter button \"mx:noop\")\n             await query.answer()\n \n+    async def _notify_clarify_expired(self, query, user_display: str) -> None:\n+        \"\"\"Tell the user a clarify tap arrived too late to be delivered.\n+\n+        Fires when the clarify entry was evicted by ``clarify_timeout`` or the\n+        gateway restarted between asking and the tap. In both cases the agent\n+        thread is no longer waiting, so the tap would otherwise leave a\n+        misleading ✓ (or an \"awaiting typed response\" prompt) on a button the\n+        agent never receives.\n+        \"\"\"\n+        try:\n+            await query.answer(text=\"⚠️ This prompt expired — please /retry.\")\n+        except Exception:\n+            pass\n+        try:\n+            await query.edit_message_text(\n+                text=(\n+                    f\"❓ {_html.escape(query.message.text or '')}\\n\\n\"\n+                    \"<i>⚠️ This question expired or the session reset — please /retry.</i>\"\n+                ),\n+                parse_mode=ParseMode.HTML,\n+                reply_markup=None,\n+            )\n+        except Exception:\n+            pass\n+\n     async def _handle_callback_query(\n         self, update: \"Update\", context: \"ContextTypes.DEFAULT_TYPE\"\n     ) -> None:","@@ -4529,12 +4554,20 @@ async def _handle_callback_query(\n                     # clarify.  Do NOT pop _clarify_state yet — we still\n                     # need it if the user is slow to respond and the entry\n                     # is cleared by something else.\n+                    flipped = False\n                     try:\n                         from tools.clarify_gateway import mark_awaiting_text\n-                        mark_awaiting_text(clarify_id)\n+                        flipped = mark_awaiting_text(clarify_id)\n                     except Exception as exc:\n                         logger.warning(\"[%s] mark_awaiting_text failed: %s\", self.name, exc)\n \n+                    if not flipped:\n+                        # Entry evicted (clarify_timeout) or gateway restarted\n+                        # between ask and tap — a typed answer would go nowhere.\n+                        self._clarify_state.pop(clarify_id, None)\n+                        await self._notify_clarify_expired(query, user_display)\n+                        return\n+\n                     await query.answer(text=\"✏️ Type your answer in the chat.\")\n                     try:\n                         await query.edit_message_text(","@@ -4580,22 +4613,25 @@ async def _handle_callback_query(\n                     logger.error(\"[%s] resolve_gateway_clarify failed: %s\", self.name, exc)\n                     resolved = False\n \n-                await query.answer(text=f\"✓ {resolved_text[:60]}\")\n-                try:\n-                    await query.edit_message_text(\n-                        text=f\"❓ {_html.escape(query.message.text or '')}\\n\\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}\",\n-                        parse_mode=ParseMode.HTML,\n-                        reply_markup=None,\n-                    )\n-                except Exception:\n-                    pass\n-\n                 if resolved:\n+                    await query.answer(text=f\"✓ {resolved_text[:60]}\")\n+                    try:\n+                        await query.edit_message_text(\n+                            text=f\"❓ {_html.escape(query.message.text or '')}\\n\\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}\",\n+                            parse_mode=ParseMode.HTML,\n+                            reply_markup=None,\n+                        )\n+                    except Exception:\n+                        pass\n                     logger.info(\n                         \"Telegram clarify button resolved (id=%s, choice=%r, user=%s)\",\n                         clarify_id, resolved_text, user_display,\n                     )\n                 else:\n+                    # Entry evicted (clarify_timeout) or gateway restarted\n+                    # between ask and tap — surface this instead of leaving a\n+                    # misleading ✓ on a button the agent will never receive.\n+                    await self._notify_clarify_expired(query, user_display)\n                     logger.warning(\n                         \"Telegram clarify button: resolve_gateway_clarify returned False (id=%s)\",\n                         clarify_id,"]},{"file":"tests/gateway/test_telegram_clarify_buttons.py","hunks":["@@ -351,6 +351,71 @@ def _is_user_authorized(self, source):\n         # State preserved\n         assert adapter._clarify_state[\"cidC\"] == \"sk-auth\"\n \n+    @pytest.mark.asyncio\n+    async def test_numeric_choice_expired_notifies_user(self):\n+        \"\"\"Late tap after the entry was evicted (timeout) or the gateway\n+        restarted must surface an expiry notice, not a misleading ✓.\"\"\"\n+        adapter = _make_adapter()\n+        # _clarify_state still maps the id (timeout eviction does not pop it),\n+        # but the clarify primitive entry is gone → resolve returns False.\n+        adapter._clarify_state[\"cidExpired\"] = \"sk-expired\"\n+\n+        query = AsyncMock()\n+        query.data = \"cl:cidExpired:0\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.message.text = \"Pick\"\n+        query.from_user = MagicMock()\n+        query.from_user.id = \"777\"\n+        query.from_user.first_name = \"Tester\"\n+        query.answer = AsyncMock()\n+        query.edit_message_text = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        with patch.dict(os.environ, {\"TELEGRAM_ALLOWED_USERS\": \"*\"}, clear=False):\n+            await adapter._handle_callback_query(update, context)\n+\n+        # User is told the prompt expired — not a misleading checkmark.\n+        answer_text = query.answer.call_args[1][\"text\"].lower()\n+        assert \"expired\" in answer_text\n+        edit_text = query.edit_message_text.call_args[1][\"text\"].lower()\n+        assert \"expired\" in edit_text or \"session reset\" in edit_text\n+        assert \"/retry\" in edit_text\n+\n+    @pytest.mark.asyncio\n+    async def test_other_button_expired_notifies_user(self):\n+        \"\"\"Tapping 'Other' after the entry was evicted must tell the user the\n+        prompt expired instead of silently entering text-capture mode.\"\"\"\n+        adapter = _make_adapter()\n+        # No clarify primitive entry → mark_awaiting_text returns False.\n+        adapter._clarify_state[\"cidOtherExpired\"] = \"sk-other-expired\"\n+\n+        query = AsyncMock()\n+        query.data = \"cl:cidOtherExpired:other\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.message.text = \"Pick\"\n+        query.from_user = MagicMock()\n+        query.from_user.id = \"777\"\n+        query.from_user.first_name = \"Tester\"\n+        query.answer = AsyncMock()\n+        query.edit_message_text = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        with patch.dict(os.environ, {\"TELEGRAM_ALLOWED_USERS\": \"*\"}, clear=False):\n+            await adapter._handle_callback_query(update, context)\n+\n+        answer_text = query.answer.call_args[1][\"text\"].lower()\n+        assert \"expired\" in answer_text\n+        # State popped so a subsequent typed message is not mis-captured.\n+        assert \"cidOtherExpired\" not in adapter._clarify_state\n+\n     @pytest.mark.asyncio\n     async def test_invalid_choice_token(self):\n         from tools import clarify_gateway as cm"]},{"file":"tests/tools/test_clarify_gateway.py","hunks":["@@ -167,11 +167,11 @@ def test_session_index_isolation(self):\n         assert b is not None and b.clarify_id == \"idB\"\n \n     def test_clarify_timeout_config_default(self):\n-        \"\"\"get_clarify_timeout returns 600 by default.\"\"\"\n+        \"\"\"get_clarify_timeout returns a positive int (default 3600).\"\"\"\n         from tools import clarify_gateway as cm\n \n         timeout = cm.get_clarify_timeout()\n-        # Default 600s OR whatever is in the user's loaded config.\n+        # Default 3600s OR whatever is in the user's loaded config.\n         # Floor check: must be a positive int, not crashed.\n         assert isinstance(timeout, int)\n         assert timeout > 0"]},{"file":"tools/clarify_gateway.py","hunks":["@@ -231,10 +231,13 @@ def clear_session(session_key: str) -> int:\n def get_clarify_timeout() -> int:\n     \"\"\"Read the clarify response timeout (seconds) from config.\n \n-    Defaults to 600 (10 minutes) — long enough for the user to type a\n-    thoughtful response, short enough that an abandoned prompt eventually\n+    Defaults to 3600 (1 hour) — long enough that a user who steps away\n+    (meeting, AFK, slow to read) still finds a live entry when they tap\n+    the button, short enough that a genuinely abandoned prompt eventually\n     unblocks the agent thread instead of pinning the running-agent guard\n-    forever.\n+    forever.  The old 600s default evicted the entry mid-think, so a late\n+    tap landed on a dead entry and the agent hung on ``running: clarify``\n+    (#32762).\n \n     Reads ``agent.clarify_timeout`` from config.yaml.\n     \"\"\"","@@ -242,9 +245,9 @@ def get_clarify_timeout() -> int:\n         from hermes_cli.config import load_config\n         cfg = load_config() or {}\n         agent_cfg = cfg.get(\"agent\", {}) or {}\n-        return int(agent_cfg.get(\"clarify_timeout\", 600))\n+        return int(agent_cfg.get(\"clarify_timeout\", 3600))\n     except Exception:\n-        return 600\n+        return 3600\n \n \n # =========================================================================\n"]}]},{"kind":"pr","number":56571,"title":"fix(tui): gracefully handle late clarify responses after timeout","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#69773","number":69773},{"kind":"pr","value":"#64886","number":64886}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56596,"title":"fix(tui): consume expired clarify responses","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61885,"title":"fix(tui): dismiss expired sensitive prompts","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_protocol.py","tui_gateway/server.py","ui-tui/README.md","ui-tui/src/__tests__/createGatewayEventHandler.test.ts","ui-tui/src/__tests__/useInputHandlers.test.ts","ui-tui/src/app/createGatewayEventHandler.ts","ui-tui/src/app/useInputHandlers.ts","ui-tui/src/app/useMainApp.ts","ui-tui/src/gatewayTypes.ts","website/docs/developer-guide/programmatic-integration.md"]},{"kind":"pr","number":69773,"title":"fix(tui_gateway): tolerate late clarify + terminal.read replies after timeout","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_protocol.py","tui_gateway/server.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}