From 9e8ee7d73e350b0b4ced2f504789b7b9b7677821 Mon Sep 17 00:00:00 2001 From: Chad Date: Tue, 14 Jul 2026 07:33:28 -0400 Subject: [PATCH 1/3] feat(olympus): add Telegram routing on live Hermes baseline --- gateway/kanban_watchers.py | 23 +- gateway/run.py | 23 + gateway/session.py | 212 ++ gateway/slash_commands.py | 558 ++++- hermes_cli/commands.py | 33 +- hermes_cli/kanban_db.py | 1750 ++++++++++++- tests/gateway/test_background_command.py | 32 +- tests/gateway/test_kanban_watchers_mixin.py | 4 +- tests/gateway/test_olympus_telegram_router.py | 2154 +++++++++++++++++ .../test_kanban_olympus_authority.py | 4 +- 10 files changed, 4677 insertions(+), 116 deletions(-) create mode 100644 tests/gateway/test_olympus_telegram_router.py diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index 3cbacdf367e0..e141be76f809 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -1306,13 +1306,32 @@ def _tick_once_for_board(slug: str) -> "Optional[object]": reconciled = _kb.reconcile_restart_state( conn, olympus_auth=olympus_auth, ) - if reconciled["effects"] or reconciled["worker_runs"]: + terminations = {"executed": 0, "confirmed": 0} + telegram_controls = 0 + if olympus_auth is not None: + terminations = _kb.process_pending_worker_termination_effects( + conn, olympus_auth=olympus_auth, + ) + telegram_controls = _kb.reconcile_olympus_telegram_controls( + conn, service_auth=olympus_auth, + ) + if ( + reconciled["effects"] + or reconciled["worker_runs"] + or terminations["executed"] + or terminations["confirmed"] + or telegram_controls + ): logger.warning( "kanban dispatcher [%s]: restart reconciliation " - "effects=%d worker_runs=%d", + "effects=%d worker_runs=%d terminations=%d " + "exits=%d telegram_controls=%d", slug, reconciled["effects"], reconciled["worker_runs"], + terminations["executed"], + terminations["confirmed"], + telegram_controls, ) return _kb.dispatch_once( conn, diff --git a/gateway/run.py b/gateway/run.py index 054bb7cb0447..b167fb5ee1de 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5070,6 +5070,23 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session ) return True # handled (silently dropped); do not fall through + # Selected Telegram input is durable Kanban intake, never a + # conversational interruption. Persist it before drain/busy handling. + routed = await self._route_olympus_telegram_intake(event) + if routed is not None: + adapter = self.adapters.get(event.source.platform) + if adapter: + reply_anchor = self._reply_anchor_for_event(event) + await adapter._send_with_retry( + chat_id=event.source.chat_id, + content=routed, + reply_to=reply_anchor, + metadata=self._thread_metadata_for_source( + event.source, reply_anchor + ), + ) + return True + # --- Draining case (gateway restarting/stopping) --- if self._draining: adapter = self._adapter_for_source(event.source) @@ -8855,6 +8872,12 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # clearly moved on. _slash_confirm_mod.clear_if_stale(_quick_key) + # A normal Telegram message in a selected Olympus lane becomes a + # durable submission before any running-agent priority/interrupt path. + _olympus_routed = await self._route_olympus_telegram_intake(event) + if _olympus_routed is not None: + return _olympus_routed + # PRIORITY handling when an agent is already running for this session. # Default behavior is to interrupt immediately so user text/stop messages # are handled with minimal latency. diff --git a/gateway/session.py b/gateway/session.py index 5ced2d8aa785..b551a56d5a6d 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -12,6 +12,7 @@ import logging import os import json +import re import threading import uuid from pathlib import Path @@ -56,6 +57,115 @@ def auto_continue_freshness_window() -> float: return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT) +_OLYMPUS_SELECTION_KEYS = { + "schema_version", + "board", + "root_task_id", + "mission_id", + "agent_id", + "authority_id", + "authority_revision", + "authority_source", + "lease_id", + "lease_revision", + "lease_source", + "scope_digest", + "bot_id", + "profile", + "caller_fingerprint", +} +_OLYMPUS_TASK_ID_RE = re.compile(r"^t_[0-9a-f]+$") +_OLYMPUS_MISSION_ID_RE = re.compile( + r"^M-20[0-9]{6}-[a-z0-9][a-z0-9-]{0,39}$" +) +_OLYMPUS_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +_OLYMPUS_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") + + +def normalize_olympus_selection(value: Any) -> Dict[str, Any]: + """Validate the small durable pointer stored for Telegram routing. + + Authority is deliberately not copied into ``sessions.json``. The + selection points at an existing governed Kanban task; callers must reload + and validate that task's current Olympus context before every create or + control operation. + """ + if not isinstance(value, dict): + raise ValueError("Olympus selection must be an object") + unknown = sorted(set(value) - _OLYMPUS_SELECTION_KEYS) + if unknown: + raise ValueError( + "Olympus selection contains unknown field(s): " + ", ".join(unknown) + ) + if value.get("schema_version") != 2: + raise ValueError("Olympus selection schema_version must be 2") + + board = str(value.get("board") or "default").strip().lower() + root_task_id = str(value.get("root_task_id") or "").strip() + mission_id = str(value.get("mission_id") or "").strip() + agent_id = str(value.get("agent_id") or "").strip().lower() + authority_id = str(value.get("authority_id") or "").strip() + authority_source = str(value.get("authority_source") or "").strip() + lease_id = str(value.get("lease_id") or "").strip() + lease_source = str(value.get("lease_source") or "").strip() + bot_id = str(value.get("bot_id") or "").strip() + profile = str(value.get("profile") or "").strip().lower() + scope_digest = str(value.get("scope_digest") or "").strip().lower() + caller_fingerprint = str( + value.get("caller_fingerprint") or "" + ).strip().lower() + if not _OLYMPUS_NAME_RE.fullmatch(board): + raise ValueError("Olympus selection board is invalid") + if not _OLYMPUS_TASK_ID_RE.fullmatch(root_task_id): + raise ValueError("Olympus selection root_task_id is invalid") + if not _OLYMPUS_MISSION_ID_RE.fullmatch(mission_id): + raise ValueError("Olympus selection mission_id is invalid") + if not _OLYMPUS_NAME_RE.fullmatch(agent_id): + raise ValueError("Olympus selection agent_id is invalid") + if not _OLYMPUS_NAME_RE.fullmatch(profile): + raise ValueError("Olympus selection profile is invalid") + for field_name, field_value in ( + ("authority_id", authority_id), + ("authority_source", authority_source), + ("lease_id", lease_id), + ("lease_source", lease_source), + ("bot_id", bot_id), + ): + if not field_value or len(field_value) > 512: + raise ValueError(f"Olympus selection {field_name} is invalid") + authority_revision = value.get("authority_revision") + lease_revision = value.get("lease_revision") + if isinstance(authority_revision, bool) or not isinstance( + authority_revision, int + ) or authority_revision < 1: + raise ValueError("Olympus selection authority_revision is invalid") + if isinstance(lease_revision, bool) or not isinstance( + lease_revision, int + ) or lease_revision < 1: + raise ValueError("Olympus selection lease_revision is invalid") + if not _OLYMPUS_DIGEST_RE.fullmatch(scope_digest): + raise ValueError("Olympus selection scope_digest is invalid") + if not _OLYMPUS_DIGEST_RE.fullmatch(caller_fingerprint): + raise ValueError("Olympus selection caller_fingerprint is invalid") + return { + "schema_version": 2, + "board": board, + "root_task_id": root_task_id, + "mission_id": mission_id, + "agent_id": agent_id, + "authority_id": authority_id, + "authority_revision": authority_revision, + "authority_source": authority_source, + "lease_id": lease_id, + "lease_revision": lease_revision, + "lease_source": lease_source, + "scope_digest": scope_digest, + "bot_id": bot_id, + "profile": profile, + "caller_fingerprint": caller_fingerprint, + } + + # --------------------------------------------------------------------------- # PII redaction helpers # --------------------------------------------------------------------------- @@ -677,6 +787,10 @@ class SessionEntry: # override is rehydrated after a restart and are never written to disk # (see sanitize_model_override / SessionStore.set_model_override). model_override: Optional[Dict[str, str]] = None + # Durable Telegram routing pointer. The referenced Kanban task remains + # the source of current authority and lease truth; this is only operator + # selection state and never grants permission by itself. + olympus_selection: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: result = { @@ -713,6 +827,10 @@ def to_dict(self) -> Dict[str, Any]: # Defence-in-depth: strip credentials even if a caller stored an # unsanitized dict directly on the entry. result["model_override"] = sanitize_model_override(self.model_override) + if self.olympus_selection is not None: + result["olympus_selection"] = normalize_olympus_selection( + self.olympus_selection + ) if self.origin: result["origin"] = self.origin.to_dict() return result @@ -748,6 +866,17 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry": f"Invalid {_field}: potential directory traversal detected" ) + olympus_selection = None + if data.get("olympus_selection") is not None: + try: + olympus_selection = normalize_olympus_selection( + data["olympus_selection"] + ) + except (TypeError, ValueError) as exc: + logger.warning( + "Ignoring invalid persisted Olympus selection: %s", exc + ) + return cls( session_key=session_key, session_id=session_id, @@ -775,6 +904,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry": auto_reset_reason=data.get("auto_reset_reason"), reset_had_activity=data.get("reset_had_activity", False), model_override=sanitize_model_override(data.get("model_override")), + olympus_selection=olympus_selection, ) @@ -1470,6 +1600,12 @@ def get_or_create_session( with self._lock: self._ensure_loaded_locked() + preserved_olympus_selection = None + if session_key in self._entries: + preserved_olympus_selection = self._entries[ + session_key + ].olympus_selection + if session_key in self._entries and not force_new: entry = self._entries[session_key] self._heal_compression_tip_locked( @@ -1590,6 +1726,7 @@ def get_or_create_session( was_auto_reset=was_auto_reset, auto_reset_reason=auto_reset_reason, reset_had_activity=reset_had_activity, + olympus_selection=preserved_olympus_selection, ) self._entries[session_key] = entry @@ -1676,6 +1813,79 @@ def get_model_override(self, session_key: str) -> Optional[Dict[str, str]]: return None return dict(entry.model_override) if entry.model_override else None + def get_olympus_selection( + self, session_key: str + ) -> Optional[Dict[str, Any]]: + """Return a defensive copy of this session's routing selection.""" + with self._lock: + self._ensure_loaded_locked() + entry = self._entries.get(session_key) + if entry is None or entry.olympus_selection is None: + return None + return dict(normalize_olympus_selection(entry.olympus_selection)) + + def set_olympus_selection( + self, + session_key: str, + selection: Optional[Dict[str, Any]], + ) -> bool: + """Persist or clear a routing selection on an existing session.""" + normalized = ( + normalize_olympus_selection(selection) + if selection is not None + else None + ) + with self._lock: + self._ensure_loaded_locked() + entry = self._entries.get(session_key) + if entry is None: + return False + entry.olympus_selection = normalized + entry.updated_at = _now() + self._save() + return True + + def compare_and_set_olympus_selection( + self, + session_key: str, + *, + expected: Optional[Dict[str, Any]], + replacement: Optional[Dict[str, Any]], + ) -> bool: + """Replace an Olympus selection only when the captured value matches. + + Verification happens outside the session lock because it may open the + Kanban database and call the canonical authority issuer. This CAS is + the persistence boundary that prevents a stale ``/olympus clear`` from + deleting a newer selection installed while that verification ran. + """ + normalized_expected = ( + normalize_olympus_selection(expected) + if expected is not None + else None + ) + normalized_replacement = ( + normalize_olympus_selection(replacement) + if replacement is not None + else None + ) + with self._lock: + self._ensure_loaded_locked() + entry = self._entries.get(session_key) + if entry is None: + return False + current = ( + normalize_olympus_selection(entry.olympus_selection) + if entry.olympus_selection is not None + else None + ) + if current != normalized_expected: + return False + entry.olympus_selection = normalized_replacement + entry.updated_at = _now() + self._save() + return True + def suspend_session(self, session_key: str) -> bool: """Mark a session as suspended so it auto-resets on next access. @@ -1861,6 +2071,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) -> platform=old_entry.platform, chat_type=old_entry.chat_type, is_fresh_reset=True, + olympus_selection=old_entry.olympus_selection, ) self._entries[session_key] = new_entry @@ -1930,6 +2141,7 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S display_name=old_entry.display_name, platform=old_entry.platform, chat_type=old_entry.chat_type, + olympus_selection=old_entry.olympus_selection, ) self._entries[session_key] = new_entry diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 56bb7cd6ab3c..a02199b157a9 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -19,6 +19,7 @@ import dataclasses import hashlib import inspect +import json import logging import os import re @@ -462,6 +463,526 @@ def _sub(): output = output[:3800] + "\n" + t("gateway.kanban.truncated_suffix") return output or t("gateway.kanban.no_output") + @staticmethod + def _olympus_json_digest(value: Any) -> str: + encoded = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _olympus_source_identity(self, event: MessageEvent) -> dict[str, str]: + source = event.source + if source is None or source.platform != Platform.TELEGRAM: + raise ValueError("the source is not Telegram") + adapter = getattr(self, "adapters", {}).get(Platform.TELEGRAM) + bot = getattr(adapter, "_bot", None) if adapter is not None else None + bot_id = str(getattr(bot, "id", "") or "").strip() + profile = str(self._active_profile_name() or "").strip().lower() + chat_id = str(getattr(source, "chat_id", "") or "").strip() + user_id = str(getattr(source, "user_id", "") or "").strip() + if not all((bot_id, profile, chat_id, user_id)): + raise ValueError( + "Telegram bot, profile, chat, and caller identity must all be known" + ) + return { + "platform": "telegram", + "bot_id": bot_id, + "profile": profile, + "chat_id": chat_id, + "thread_id": str(getattr(source, "thread_id", "") or ""), + "user_id": user_id, + } + + @staticmethod + def _olympus_delivery_identity( + event: MessageEvent, source_identity: dict[str, str] + ) -> dict[str, Any]: + identity: dict[str, Any] = { + "platform": "telegram", + "bot_id": source_identity["bot_id"], + "profile": source_identity["profile"], + } + if event.platform_update_id is not None: + identity["update_id"] = int(event.platform_update_id) + return identity + message_id = str(event.message_id or "").strip() + if not message_id: + raise ValueError( + "Telegram supplied no stable update or message identifier" + ) + identity.update( + {"chat_id": source_identity["chat_id"], "message_id": message_id} + ) + return identity + + @staticmethod + def _current_olympus_task(kb, task) -> dict[str, Any]: + if task is None: + raise ValueError("governed task does not exist") + if getattr(task, "status", None) == "archived": + raise ValueError("governed task is archived") + return kb._require_current_olympus_context( + getattr(task, "olympus_context", None), + assignee=getattr(task, "assignee", None), + ) + + def _validate_olympus_selection_binding( + self, + selection: dict[str, Any], + context: dict[str, Any], + source_identity: dict[str, str], + ) -> None: + authority = context["authority"] + lease = context["lease"] + expected = { + "mission_id": context["mission_id"], + "agent_id": context["agent_id"], + "authority_id": authority["authority_id"], + "authority_revision": authority["revision"], + "authority_source": authority["source"], + "lease_id": lease["lease_id"], + "lease_revision": lease["revision"], + "lease_source": lease["source"], + "scope_digest": self._olympus_json_digest(authority["scope"]), + "bot_id": source_identity["bot_id"], + "profile": source_identity["profile"], + "caller_fingerprint": self._olympus_json_digest(source_identity), + } + if any(selection.get(key) != value for key, value in expected.items()): + raise ValueError( + "selection is stale, foreign, wrong-source, or caller-conflicted" + ) + + def _verify_olympus_root( + self, + kb, + conn, + task, + *, + source_identity: dict[str, str], + capability: str, + action: str, + operation_id: str, + selection: Optional[dict[str, Any]] = None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + context = self._current_olympus_task(kb, task) + if selection is not None: + self._validate_olympus_selection_binding( + selection, context, source_identity + ) + verification = kb.verify_olympus_telegram_task( + conn, + authorization_task_id=task.id, + target_task_id=task.id, + action=action, + verifier=getattr( + self, "_kanban_olympus_authority_verifier", None + ), + source_identity=source_identity, + operation_id=operation_id, + ) + if verification["request"]["capability"] != capability: + raise ValueError("Telegram capability did not match the frozen action") + return context, verification + + @staticmethod + def _olympus_operator_tag(event: MessageEvent) -> str: + source = event.source + stable_id = str(getattr(source, "user_id", "") or "unknown") + digest = hashlib.sha256(stable_id.encode("utf-8")).hexdigest()[:12] + return f"telegram:{digest}" + + def _olympus_selection_for_event( + self, event: MessageEvent + ) -> Optional[dict[str, Any]]: + store = getattr(self, "session_store", None) + if store is None or not hasattr(store, "get_olympus_selection"): + return None + session_key = self._session_key_for_source(event.source) + selection = store.get_olympus_selection(session_key) + return selection if isinstance(selection, dict) else None + + async def _route_olympus_telegram_intake( + self, + event: MessageEvent, + *, + prompt_override: Optional[str] = None, + ) -> Optional[str]: + """Route selected Telegram input into the existing Kanban. + + ``None`` means the event is outside this route. Any selected Telegram + event returns a visible success or fail-closed response and never falls + through to the conversational agent. + """ + source = event.source + if source is None or source.platform != Platform.TELEGRAM: + return None + if prompt_override is None and event.get_command(): + return None + + selection = self._olympus_selection_for_event(event) + if selection is None: + return None + + prompt = str( + prompt_override if prompt_override is not None else event.text or "" + ).strip() + if not prompt: + return "Olympus intake blocked: the task prompt is empty." + if event.media_urls or event.media_types: + return ( + "Olympus intake blocked: durable attachment transfer is not " + "certified yet. Use `/background --ephemeral ...` for this " + "message or submit a text-only task." + ) + + session_entry = self.session_store.get_or_create_session(source) + operator_tag = self._olympus_operator_tag(event) + board = selection["board"] + root_task_id = selection["root_task_id"] + mission_id = selection["mission_id"] + agent_id = selection["agent_id"] + title = " ".join(prompt.split())[:120] + + def _create() -> str: + from hermes_cli import kanban_db as kb + + source_identity = self._olympus_source_identity(event) + delivery_identity = self._olympus_delivery_identity( + event, source_identity + ) + delivery_digest = self._olympus_json_digest(delivery_identity) + delivery_key = f"olympus-telegram:v3:{delivery_digest}" + operation_id = f"olympus-telegram-intake:v3:{delivery_digest}" + if board != "default" and not kb.board_exists(board): + raise ValueError(f"Kanban board {board!r} does not exist") + conn = kb.connect(board=board) + try: + root = kb.get_task(conn, root_task_id) + root_context = self._current_olympus_task(kb, root) + self._validate_olympus_selection_binding( + selection, root_context, source_identity + ) + verifier = getattr( + self, "_kanban_olympus_authority_verifier", None + ) + telegram_auth = kb.olympus_telegram_auth( + conn, + verifier=verifier, + source_identity=source_identity, + authorization_task_id=root.id, + target_task_id=root.id, + action="telegram-intake", + operation_id=operation_id, + ) + service_auth = kb.olympus_service_auth( + conn, + verifier=verifier, + dispatcher_instance_id=getattr( + self, "_kanban_dispatcher_instance_id", "" + ), + actor=root_context["agent_id"], + operation_id=f"{operation_id}:service", + ) + task_id = kb.create_olympus_telegram_task( + conn, + telegram_auth=telegram_auth, + service_auth=service_auth, + delivery_key=delivery_key, + delivery_identity=delivery_identity, + title=title, + body=prompt, + assignee=root_context["agent_id"], + created_by=operator_tag, + session_id=session_entry.session_id, + platform="telegram", + chat_id=str(source.chat_id), + thread_id=( + str(source.thread_id) if source.thread_id else None + ), + user_id=str(source.user_id) if source.user_id else None, + notifier_profile=source_identity["profile"], + board=board, + ) + return task_id + finally: + conn.close() + + try: + task_id = await asyncio.to_thread(_create) + except Exception as exc: + logger.warning( + "Olympus Telegram intake denied for mission %s: %s", + mission_id, + exc, + ) + return f"Olympus intake blocked: {exc}" + + result = ( + f"Queued `{task_id}` for mission `{mission_id}` on board " + f"`{board}` with agent `{agent_id}`." + ) + return result + + async def _handle_olympus_command(self, event: MessageEvent) -> str: + """Manage durable Telegram selection and explicitly targeted controls.""" + if event.source.platform != Platform.TELEGRAM: + return "`/olympus` is currently available only on Telegram." + try: + tokens = shlex.split(event.get_command_args().strip()) + except ValueError as exc: + return f"Olympus command parse error: {exc}" + action = tokens[0].lower() if tokens else "status" + args = tokens[1:] + session_entry = self.session_store.get_or_create_session(event.source) + session_key = session_entry.session_key + + usage = ( + "Usage:\n" + "`/olympus select [--board ]`\n" + "`/olympus status` | `/olympus clear`\n" + "`/olympus pause|resume|interrupt|cancel `" + ) + + if action == "clear": + if args: + return usage + selection = self.session_store.get_olympus_selection(session_key) + if selection is None: + return "No Olympus durable intake is selected." + try: + source_identity = self._olympus_source_identity(event) + delivery_identity = self._olympus_delivery_identity( + event, source_identity + ) + operation_id = ( + "olympus-telegram-command:v3:" + + self._olympus_json_digest(delivery_identity) + ) + from hermes_cli import kanban_db as kb + conn = kb.connect(board=selection["board"]) + try: + root = kb.get_task(conn, selection["root_task_id"]) + self._verify_olympus_root( + kb, + conn, + root, + source_identity=source_identity, + capability=kb.OLYMPUS_CAPABILITY_TELEGRAM_CLEAR, + action="telegram-clear", + operation_id=operation_id, + selection=selection, + ) + finally: + conn.close() + except Exception as exc: + return f"Olympus clear blocked: {exc}" + if not self.session_store.compare_and_set_olympus_selection( + session_key, expected=selection, replacement=None + ): + return ( + "Olympus clear not applied: the selection changed during " + "authority verification. The newer selection was preserved." + ) + return "Olympus durable intake selection cleared." + + if action == "select": + if not args: + return usage + root_task_id = args[0] + board = "default" + i = 1 + while i < len(args): + token = args[i] + if token == "--board" and i + 1 < len(args): + board = args[i + 1] + i += 2 + continue + return usage + + def _select() -> dict[str, Any]: + from gateway.session import normalize_olympus_selection + from hermes_cli import kanban_db as kb + + source_identity = self._olympus_source_identity(event) + delivery_identity = self._olympus_delivery_identity( + event, source_identity + ) + operation_id = ( + "olympus-telegram-command:v3:" + + self._olympus_json_digest(delivery_identity) + ) + board_normalized = str(board).strip().lower() + if board_normalized != "default" and not kb.board_exists( + board_normalized + ): + raise ValueError( + f"Kanban board {board_normalized!r} does not exist" + ) + conn = kb.connect(board=board_normalized) + try: + root = kb.get_task(conn, root_task_id) + context, _ = self._verify_olympus_root( + kb, + conn, + root, + source_identity=source_identity, + capability=kb.OLYMPUS_CAPABILITY_TELEGRAM_SELECT, + action="telegram-select", + operation_id=operation_id, + ) + finally: + conn.close() + authority = context["authority"] + lease = context["lease"] + return normalize_olympus_selection( + { + "schema_version": 2, + "board": board_normalized, + "root_task_id": root_task_id, + "mission_id": context["mission_id"], + "agent_id": context["agent_id"], + "authority_id": authority["authority_id"], + "authority_revision": authority["revision"], + "authority_source": authority["source"], + "lease_id": lease["lease_id"], + "lease_revision": lease["revision"], + "lease_source": lease["source"], + "scope_digest": self._olympus_json_digest( + authority["scope"] + ), + "bot_id": source_identity["bot_id"], + "profile": source_identity["profile"], + "caller_fingerprint": self._olympus_json_digest( + source_identity + ), + } + ) + + try: + selection = await asyncio.to_thread(_select) + except Exception as exc: + return f"Olympus selection blocked: {exc}" + self.session_store.set_olympus_selection(session_key, selection) + return ( + f"Olympus durable intake selected mission " + f"`{selection['mission_id']}`, root `{selection['root_task_id']}`, " + f"board `{selection['board']}`, agent `{selection['agent_id']}`." + ) + + selection = self.session_store.get_olympus_selection(session_key) + if action == "status": + if args: + return usage + if selection is None: + return "No Olympus durable intake is selected.\n" + usage + + def _status() -> str: + from hermes_cli import kanban_db as kb + + source_identity = self._olympus_source_identity(event) + delivery_identity = self._olympus_delivery_identity( + event, source_identity + ) + conn = kb.connect(board=selection["board"]) + try: + root = kb.get_task(conn, selection["root_task_id"]) + self._verify_olympus_root( + kb, + conn, + root, + source_identity=source_identity, + capability=kb.OLYMPUS_CAPABILITY_TELEGRAM_STATUS, + action="telegram-status", + operation_id=( + "olympus-telegram-command:v3:" + + self._olympus_json_digest(delivery_identity) + ), + selection=selection, + ) + return str(getattr(root, "status", "unknown")) + finally: + conn.close() + + try: + root_status = await asyncio.to_thread(_status) + authority_state = "current" + except Exception as exc: + root_status = "blocked" + authority_state = f"invalid: {exc}" + return ( + f"Olympus selection: mission `{selection['mission_id']}`, root " + f"`{selection['root_task_id']}` ({root_status}), board " + f"`{selection['board']}`, agent `{selection['agent_id']}`, " + f"authority {authority_state}." + ) + + if action not in {"pause", "resume", "interrupt", "cancel"}: + return usage + if selection is None: + return "Olympus control blocked: select a governed mission root first." + if len(args) != 1: + return usage + task_id = args[0] + operator_tag = self._olympus_operator_tag(event) + + def _control() -> str: + from hermes_cli import kanban_db as kb + + source_identity = self._olympus_source_identity(event) + delivery_identity = self._olympus_delivery_identity( + event, source_identity + ) + operation_id = ( + "olympus-telegram-control:v3:" + + self._olympus_json_digest(delivery_identity) + ) + conn = kb.connect(board=selection["board"]) + try: + root = kb.get_task(conn, selection["root_task_id"]) + root_context = self._current_olympus_task(kb, root) + self._validate_olympus_selection_binding( + selection, root_context, source_identity + ) + verifier = getattr( + self, "_kanban_olympus_authority_verifier", None + ) + telegram_auth = kb.olympus_telegram_auth( + conn, + verifier=verifier, + source_identity=source_identity, + authorization_task_id=root.id, + target_task_id=task_id, + action=f"telegram-control:{action}", + operation_id=operation_id, + ) + service_auth = kb.olympus_service_auth( + conn, + verifier=verifier, + dispatcher_instance_id=getattr( + self, "_kanban_dispatcher_instance_id", "" + ), + actor=root_context["agent_id"], + operation_id=f"{operation_id}:service", + ) + result = kb.apply_olympus_telegram_control( + conn, + telegram_auth=telegram_auth, + service_auth=service_auth, + target_task_id=task_id, + action=action, + operator_tag=operator_tag, + ) + return str(result["status"]) + finally: + conn.close() + + try: + new_status = await asyncio.to_thread(_control) + except Exception as exc: + return f"Olympus {action} blocked: {exc}" + return f"Olympus {action} applied to `{task_id}`; status is `{new_status}`." + async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model @@ -2567,15 +3088,40 @@ async def _handle_rollback_command(self, event: MessageEvent) -> str: return t("gateway.rollback.restore_failed", error=result["error"]) async def _handle_background_command(self, event: MessageEvent) -> str: - """Handle /background — run a prompt in a separate background session. + """Handle durable Telegram or explicitly ephemeral background work. - Spawns a new AIAgent in a background thread with its own session. - When it completes, sends the result back to the same chat without - modifying the active session's conversation history. + Selected Telegram sessions submit to Hermes Kanban by default. The + historical process-local AIAgent path remains available there only via + ``--ephemeral``. Other platforms retain their existing behavior. """ - prompt = event.get_command_args().strip() + raw_args = event.get_command_args().strip() + ephemeral = False + if raw_args == "--ephemeral": + ephemeral = True + raw_args = "" + elif raw_args.startswith("--ephemeral "): + ephemeral = True + raw_args = raw_args[len("--ephemeral "):].lstrip() + prompt = raw_args if not prompt: - return t("gateway.background.usage") + return ( + "Usage: /background [--ephemeral] " + if event.source.platform == Platform.TELEGRAM + else t("gateway.background.usage") + ) + + if event.source.platform == Platform.TELEGRAM and not ephemeral: + routed = await self._route_olympus_telegram_intake( + event, + prompt_override=prompt, + ) + if routed is not None: + return routed + return ( + "Durable background submission requires an Olympus selection. " + "Use `/olympus select ` first, or explicitly use " + "`/background --ephemeral ` for process-local work." + ) source = event.source task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}" diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 7a8775d26041..c5efe1a63916 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -99,8 +99,13 @@ class CommandDef: gateway_only=True, args_hint="[session|always]"), CommandDef("deny", "Deny a pending dangerous command", "Session", gateway_only=True), - CommandDef("background", "Run a prompt in the background", "Session", - aliases=("bg", "btw"), args_hint=""), + CommandDef( + "background", + "Submit durable Telegram work or run an explicit ephemeral background prompt", + "Session", + aliases=("bg", "btw"), + args_hint="[--ephemeral] ", + ), CommandDef("agents", "Show active agents and running tasks", "Session", aliases=("tasks",)), CommandDef("journey", "Open the learning journey timeline", @@ -210,6 +215,22 @@ class CommandDef: "archive", "tail", "dispatch", "stats", "notify-subscribe", "notify-list", "notify-unsubscribe", "log", "runs", "heartbeat", "assignees", "context", "specify", "gc")), + CommandDef( + "olympus", + "Select governed Kanban intake or control an explicitly targeted task", + "Tools & Skills", + gateway_only=True, + args_hint="[status|select|clear|pause|resume|interrupt|cancel]", + subcommands=( + "status", + "select", + "clear", + "pause", + "resume", + "interrupt", + "cancel", + ), + ), CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills", cli_only=True), CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", @@ -373,6 +394,7 @@ def is_gateway_known_command(name: str | None) -> bool: "deny", "help", "new", + "olympus", "profile", "queue", "restart", @@ -1163,7 +1185,12 @@ def discord_skill_commands_by_category( # - moa: high-cost slash mode, available through /hermes moa to avoid # displacing existing native Slack slash commands at the 50-command cap. # - debug: the log/report upload surface; reached via /hermes debug on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug"}) +# - olympus: Telegram-only durable mission intake in its first certified lane; +# Slack keeps the catch-all `/hermes olympus ...` route without consuming a +# native command slot. +_SLACK_VIA_HERMES_ONLY = frozenset( + {"credits", "billing", "moa", "debug", "olympus"} +) def _sanitize_slack_name(raw: str) -> str: diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index d9072844aba6..85164f25eb1c 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -89,7 +89,7 @@ import logging import time from contextvars import ContextVar, Token -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any, Callable, Iterable, Mapping, Optional @@ -332,6 +332,10 @@ def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None "telegram-control:interrupt": "telegram.olympus.emergency.interrupt", "telegram-control:cancel": "telegram.olympus.emergency.cancel", } +OLYMPUS_CAPABILITY_TELEGRAM_SELECT = TELEGRAM_ACTION_CAPABILITIES["telegram-select"] +OLYMPUS_CAPABILITY_TELEGRAM_STATUS = TELEGRAM_ACTION_CAPABILITIES["telegram-status"] +OLYMPUS_CAPABILITY_TELEGRAM_CLEAR = TELEGRAM_ACTION_CAPABILITIES["telegram-clear"] +OLYMPUS_CAPABILITY_TELEGRAM_INTAKE = TELEGRAM_ACTION_CAPABILITIES["telegram-intake"] TELEGRAM_EMERGENCY_ACTIONS = frozenset({ "telegram-control:interrupt", "telegram-control:cancel", }) @@ -493,6 +497,14 @@ def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None ("claim_notification_effect", OLYMPUS_CAPABILITY_NOTIFY): frozenset(), ("finish_notification_effect", OLYMPUS_CAPABILITY_NOTIFY): frozenset(), ("reconcile_effect_journal", OLYMPUS_CAPABILITY_RECOVER): frozenset(), + # Telegram permits authorize only immutable intake/control journals or a + # read-only verification. They never authorize task-column mutation; + # the trusted service dispatcher obtains a separate action-specific + # permit for any resulting Kanban state transition. + **{ + (action, capability): frozenset() + for action, capability in TELEGRAM_ACTION_CAPABILITIES.items() + }, } _OLYMPUS_RUN_WRITE_COLUMNS: dict[tuple[str, str], frozenset[str]] = { @@ -646,6 +658,8 @@ def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None "olympus-task-release-receipt-attestation/1" ) RELEASE_AUTHORITY_PROOF_SCHEMA = "olympus-task-release-authority-proof/1" +TELEGRAM_DELIVERY_WRITE_SCHEMA = "kanban-telegram-delivery-write/1" +TELEGRAM_CONTROL_WRITE_SCHEMA = "kanban-telegram-control-write/1" _OLYMPUS_RELEASE_RECEIPT_KEYS = frozenset({ "schema_version", "operation_id", "task_id", "previous_status", "status", "previous_revision", "record_revision", "verification_id", "request_id", @@ -684,9 +698,22 @@ def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None "schema_version", "action", "task_id", "task_record_revision", "dispatcher_instance_id", }) +_TELEGRAM_DELIVERY_WRITE_KEYS = frozenset({ + "schema_version", "action", "task_id", "task_record_revision", + "delivery_key", "authorization_task_id", "authorization_task_revision", + "created_task_id", "payload", "payload_sha256", "created_at", +}) +_TELEGRAM_CONTROL_WRITE_KEYS = frozenset({ + "schema_version", "action", "task_id", "task_record_revision", + "operation_id", "authorization_task_id", "authorization_task_revision", + "source_identity", "request_payload", "payload_sha256", + "result_status", "effect_operation_id", "created_at", +}) _EXACT_MUTATION_BINDING_ACTIONS = frozenset({ "reserve_notification_effect", "claim_notification_effect", "finish_notification_effect", "register_worker_process", + "telegram-intake", "telegram-control:pause", "telegram-control:resume", + "telegram-control:interrupt", "telegram-control:cancel", }) @@ -1702,6 +1729,130 @@ def _normalize_exact_mutation_binding( "mutation_binding.dispatcher_instance_id", ), } + elif action == "telegram-intake": + raw = _require_exact_keys( + value, _TELEGRAM_DELIVERY_WRITE_KEYS, "mutation_binding", + ) + if ( + raw["schema_version"] != TELEGRAM_DELIVERY_WRITE_SCHEMA + or raw["action"] != action + ): + raise AuthorityContractError( + "Telegram delivery binding has the wrong schema/action" + ) + payload = _canonical_json_text( + raw["payload"], "mutation_binding.payload" + ) + digest = _strict_text( + raw["payload_sha256"], "mutation_binding.payload_sha256" + ) + if ( + not re.fullmatch(r"[0-9a-f]{64}", digest) + or hashlib.sha256(payload.encode("utf-8")).hexdigest() != digest + ): + raise AuthorityContractError( + "mutation_binding.payload_sha256 does not match canonical payload" + ) + normalized = { + "schema_version": TELEGRAM_DELIVERY_WRITE_SCHEMA, + "action": action, + "task_id": _strict_text(raw["task_id"], "mutation_binding.task_id"), + "task_record_revision": _strict_int( + raw["task_record_revision"], + "mutation_binding.task_record_revision", minimum=1, + ), + "delivery_key": _strict_text( + raw["delivery_key"], "mutation_binding.delivery_key" + ), + "authorization_task_id": _strict_text( + raw["authorization_task_id"], + "mutation_binding.authorization_task_id", + ), + "authorization_task_revision": _strict_int( + raw["authorization_task_revision"], + "mutation_binding.authorization_task_revision", minimum=1, + ), + "created_task_id": _strict_text( + raw["created_task_id"], "mutation_binding.created_task_id" + ), + "payload": payload, + "payload_sha256": digest, + "created_at": _strict_int( + raw["created_at"], "mutation_binding.created_at", minimum=0, + ), + } + if normalized["authorization_task_id"] != normalized["task_id"] or ( + normalized["authorization_task_revision"] + != normalized["task_record_revision"] + ): + raise AuthorityContractError( + "Telegram delivery authorization root is not the exact target" + ) + elif action.startswith("telegram-control:"): + raw = _require_exact_keys( + value, _TELEGRAM_CONTROL_WRITE_KEYS, "mutation_binding", + ) + if ( + raw["schema_version"] != TELEGRAM_CONTROL_WRITE_SCHEMA + or raw["action"] != action + ): + raise AuthorityContractError( + "Telegram control binding has the wrong schema/action" + ) + source = _canonical_json_text( + raw["source_identity"], "mutation_binding.source_identity" + ) + payload = _canonical_json_text( + raw["request_payload"], "mutation_binding.request_payload" + ) + digest = _strict_text( + raw["payload_sha256"], "mutation_binding.payload_sha256" + ) + if ( + not re.fullmatch(r"[0-9a-f]{64}", digest) + or hashlib.sha256(payload.encode("utf-8")).hexdigest() != digest + ): + raise AuthorityContractError( + "mutation_binding.payload_sha256 does not match canonical payload" + ) + result_status = raw["result_status"] + if result_status is not None: + result_status = _strict_text( + result_status, "mutation_binding.result_status" + ) + effect_operation_id = raw["effect_operation_id"] + if effect_operation_id is not None: + effect_operation_id = _strict_text( + effect_operation_id, "mutation_binding.effect_operation_id" + ) + normalized = { + "schema_version": TELEGRAM_CONTROL_WRITE_SCHEMA, + "action": action, + "task_id": _strict_text(raw["task_id"], "mutation_binding.task_id"), + "task_record_revision": _strict_int( + raw["task_record_revision"], + "mutation_binding.task_record_revision", minimum=1, + ), + "operation_id": _strict_text( + raw["operation_id"], "mutation_binding.operation_id" + ), + "authorization_task_id": _strict_text( + raw["authorization_task_id"], + "mutation_binding.authorization_task_id", + ), + "authorization_task_revision": _strict_int( + raw["authorization_task_revision"], + "mutation_binding.authorization_task_revision", minimum=1, + ), + "source_identity": source, + "request_payload": payload, + "payload_sha256": digest, + "result_status": result_status, + "effect_operation_id": effect_operation_id, + "created_at": _strict_int( + raw["created_at"], "mutation_binding.created_at", minimum=0, + ), + } else: raise AuthorityContractError( f"action {action!r} does not accept an exact mutation binding" @@ -3970,6 +4121,38 @@ class Event: UNIQUE(task_id, subject_revision) ); +-- One immutable Telegram delivery may create exactly one governed task. The +-- payload includes the authenticated source, selected authorization root, +-- delegated agent, task request, and notification destination. +CREATE TABLE IF NOT EXISTS olympus_telegram_deliveries ( + delivery_key TEXT PRIMARY KEY, + authorization_task_id TEXT NOT NULL, + authorization_task_revision INTEGER NOT NULL, + task_id TEXT NOT NULL UNIQUE, + payload TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +-- Immutable request/result receipt for an operator control. Running-task +-- interruption is represented by the certified worker effect journal; this +-- table never owns PIDs or process execution state. +CREATE TABLE IF NOT EXISTS olympus_telegram_controls ( + operation_id TEXT PRIMARY KEY, + action TEXT NOT NULL, + authorization_task_id TEXT NOT NULL, + authorization_task_revision INTEGER NOT NULL, + target_task_id TEXT NOT NULL, + target_task_revision INTEGER NOT NULL, + source_identity TEXT NOT NULL, + request_payload TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + verification_id TEXT NOT NULL, + result_status TEXT, + effect_operation_id TEXT, + created_at INTEGER NOT NULL +); + -- Immutable release-time authority evidence. The receipt wire stays v1 while -- this sidecar preserves the exact accepted v3 request and verification needed -- to attest history after the live authority or lease later expires/revokes. @@ -4071,6 +4254,9 @@ class Event: _OLYMPUS_GUARD_INSTALL_FAILPOINT: Optional[Callable[[], None]] = None _OLYMPUS_REBUILD_FAILPOINT: Optional[Callable[[str], None]] = None _OLYMPUS_EFFECT_EXECUTION_FAILPOINT: Optional[Callable[[str], None]] = None +_OLYMPUS_TELEGRAM_MIGRATION_FAILPOINT: Optional[ + Callable[[str], None] +] = None _SQLITE_HEADER = b"SQLite format 3\x00" DEFAULT_BUSY_TIMEOUT_MS = 120_000 @@ -4744,6 +4930,83 @@ def connect( return conn +def _olympus_telegram_journal_guard_sql( + conn: sqlite3.Connection, +) -> str: + """Build v3 journal guards only after their exact schemas exist. + + A pre-v3 WIP database can already have persistent task guards, which means + :func:`connect` must register the general guard UDFs before migration. It + must not create v3 ``NEW.column`` triggers against the legacy Telegram + table shape, however, because SQLite then cannot rename that table. Schema + migration preserves/replaces the legacy tables first; the second guard + installation pass calls this helper again and installs these triggers. + """ + expected = { + "olympus_telegram_deliveries": { + "delivery_key", "authorization_task_id", + "authorization_task_revision", "task_id", "payload", + "payload_sha256", "created_at", + }, + "olympus_telegram_controls": { + "operation_id", "action", "authorization_task_id", + "authorization_task_revision", "target_task_id", + "target_task_revision", "source_identity", "request_payload", + "payload_sha256", "verification_id", "result_status", + "effect_operation_id", "created_at", + }, + } + exact = { + table: { + row["name"] + for row in conn.execute(f"PRAGMA table_info({table})") + } == columns + for table, columns in expected.items() + } + statements: list[str] = [] + if exact["olympus_telegram_deliveries"]: + statements.extend(( + "CREATE TRIGGER olympus_telegram_delivery_insert_guard " + "BEFORE INSERT ON olympus_telegram_deliveries " + "WHEN olympus_telegram_delivery_insert_allowed(" + "NEW.delivery_key,NEW.authorization_task_id," + "NEW.authorization_task_revision,NEW.task_id,NEW.payload," + "NEW.payload_sha256,NEW.created_at) != 1 " + "BEGIN SELECT RAISE(ABORT, " + "'olympus_telegram_delivery_authority_required'); END;", + "CREATE TRIGGER olympus_telegram_delivery_update_guard " + "BEFORE UPDATE ON olympus_telegram_deliveries " + "BEGIN SELECT RAISE(ABORT, " + "'olympus_telegram_delivery_immutable'); END;", + "CREATE TRIGGER olympus_telegram_delivery_delete_guard " + "BEFORE DELETE ON olympus_telegram_deliveries " + "BEGIN SELECT RAISE(ABORT, " + "'olympus_telegram_delivery_immutable'); END;", + )) + if exact["olympus_telegram_controls"]: + statements.extend(( + "CREATE TRIGGER olympus_telegram_control_insert_guard " + "BEFORE INSERT ON olympus_telegram_controls " + "WHEN olympus_telegram_control_insert_allowed(" + "NEW.operation_id,NEW.action,NEW.authorization_task_id," + "NEW.authorization_task_revision,NEW.target_task_id," + "NEW.target_task_revision,NEW.source_identity," + "NEW.request_payload,NEW.payload_sha256,NEW.verification_id," + "NEW.result_status,NEW.effect_operation_id,NEW.created_at) != 1 " + "BEGIN SELECT RAISE(ABORT, " + "'olympus_telegram_control_authority_required'); END;", + "CREATE TRIGGER olympus_telegram_control_update_guard " + "BEFORE UPDATE ON olympus_telegram_controls " + "BEGIN SELECT RAISE(ABORT, " + "'olympus_telegram_control_immutable'); END;", + "CREATE TRIGGER olympus_telegram_control_delete_guard " + "BEFORE DELETE ON olympus_telegram_controls " + "BEGIN SELECT RAISE(ABORT, " + "'olympus_telegram_control_immutable'); END;", + )) + return "\n".join(statements) + + def _install_olympus_write_guard(conn: sqlite3.Connection) -> None: """Install persistent main-schema guards backed by process-local UDFs. @@ -5167,6 +5430,78 @@ def _release_receipt_insert_allowed( return 0 return int(exact) + def _telegram_delivery_insert_allowed( + delivery_key: Any, authorization_task_id: Any, + authorization_task_revision: Any, task_id: Any, payload: Any, + payload_sha256: Any, created_at: Any, + ) -> int: + issued = _permit(authorization_task_id) + binding = _permit_write_binding(issued) + try: + exact = ( + issued is not None + and issued[2:4] == ( + "telegram-intake", TELEGRAM_ACTION_CAPABILITIES["telegram-intake"], + ) + and issued[0] == int(authorization_task_revision) + and binding == { + "schema_version": TELEGRAM_DELIVERY_WRITE_SCHEMA, + "action": "telegram-intake", + "task_id": str(authorization_task_id), + "task_record_revision": int(authorization_task_revision), + "delivery_key": delivery_key, + "authorization_task_id": authorization_task_id, + "authorization_task_revision": int( + authorization_task_revision + ), + "created_task_id": task_id, + "payload": payload, + "payload_sha256": payload_sha256, + "created_at": int(created_at), + } + ) + except (TypeError, ValueError): + return 0 + return int(exact) + + def _telegram_control_insert_allowed( + operation_id: Any, action: Any, authorization_task_id: Any, + authorization_task_revision: Any, target_task_id: Any, + target_task_revision: Any, source_identity: Any, + request_payload: Any, payload_sha256: Any, verification_id: Any, + result_status: Any, effect_operation_id: Any, created_at: Any, + ) -> int: + issued = _permit(target_task_id) + binding = _permit_write_binding(issued) + try: + exact = ( + issued is not None + and issued[2] == action + and issued[3] == TELEGRAM_ACTION_CAPABILITIES.get(str(action)) + and issued[0] == int(target_task_revision) + and issued[6] == verification_id + and binding == { + "schema_version": TELEGRAM_CONTROL_WRITE_SCHEMA, + "action": action, + "task_id": str(target_task_id), + "task_record_revision": int(target_task_revision), + "operation_id": operation_id, + "authorization_task_id": authorization_task_id, + "authorization_task_revision": int( + authorization_task_revision + ), + "source_identity": source_identity, + "request_payload": request_payload, + "payload_sha256": payload_sha256, + "result_status": result_status, + "effect_operation_id": effect_operation_id, + "created_at": int(created_at), + } + ) + except (TypeError, ValueError): + return 0 + return int(exact) + def _release_authority_proof_insert_allowed( operation_id: Any, task_id: Any, subject_revision: Any, proof: Any, proof_sha256: Any, created_at: Any, @@ -5389,7 +5724,8 @@ def _effect_update_allowed( }, "settle_worker_termination": { ("applying", "applied"), ("applying", "gone"), - ("applying", "unknown"), + ("applying", "unknown"), ("applied", "gone"), + ("applied", "identity_mismatch"), }, }.get(action, set()) if str(effect_kind).startswith("notify_") and action.startswith("execute_worker"): @@ -5460,6 +5796,14 @@ def _effect_update_allowed( "olympus_release_receipt_insert_allowed", 9, _release_receipt_insert_allowed, ) + conn.create_function( + "olympus_telegram_delivery_insert_allowed", 7, + _telegram_delivery_insert_allowed, + ) + conn.create_function( + "olympus_telegram_control_insert_allowed", 13, + _telegram_control_insert_allowed, + ) conn.create_function( "olympus_release_authority_proof_insert_allowed", 6, _release_authority_proof_insert_allowed, @@ -5478,6 +5822,7 @@ def _effect_update_allowed( run_values = ", ".join( f"OLD.{column}, NEW.{column}" for column in _OLYMPUS_RUN_MUTABLE_COLUMNS ) + telegram_guard_sql = _olympus_telegram_journal_guard_sql(conn) script = f""" BEGIN IMMEDIATE; DROP TRIGGER IF EXISTS effect_journal_immutable; @@ -5504,6 +5849,12 @@ def _effect_update_allowed( DROP TRIGGER IF EXISTS olympus_release_receipt_insert_guard; DROP TRIGGER IF EXISTS olympus_release_receipt_update_guard; DROP TRIGGER IF EXISTS olympus_release_receipt_delete_guard; + DROP TRIGGER IF EXISTS olympus_telegram_delivery_insert_guard; + DROP TRIGGER IF EXISTS olympus_telegram_delivery_update_guard; + DROP TRIGGER IF EXISTS olympus_telegram_delivery_delete_guard; + DROP TRIGGER IF EXISTS olympus_telegram_control_insert_guard; + DROP TRIGGER IF EXISTS olympus_telegram_control_update_guard; + DROP TRIGGER IF EXISTS olympus_telegram_control_delete_guard; DROP TRIGGER IF EXISTS olympus_release_proof_insert_guard; DROP TRIGGER IF EXISTS olympus_release_proof_update_guard; DROP TRIGGER IF EXISTS olympus_release_proof_delete_guard; @@ -5719,6 +6070,8 @@ def _effect_update_allowed( BEFORE DELETE ON kanban_olympus_release_receipts BEGIN SELECT RAISE(ABORT, 'olympus_release_receipt_immutable'); END; + {telegram_guard_sql} + CREATE TRIGGER olympus_release_proof_insert_guard BEFORE INSERT ON kanban_olympus_release_authority_proofs WHEN olympus_release_authority_proof_insert_allowed( @@ -6213,6 +6566,66 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: conn, "kanban_notify_subs", "notifier_profile", "notifier_profile TEXT" ) + # Pre-v3 Telegram WIP tables carried caller-asserted authority and, for + # controls, PID-owned recovery state. Preserve those rows as immutable + # legacy evidence but never treat them as executable v3 receipts. + telegram_specs = { + "olympus_telegram_deliveries": ( + { + "delivery_key", "authorization_task_id", + "authorization_task_revision", "task_id", "payload", + "payload_sha256", "created_at", + }, + "CREATE TABLE olympus_telegram_deliveries (" + "delivery_key TEXT PRIMARY KEY, authorization_task_id TEXT NOT NULL, " + "authorization_task_revision INTEGER NOT NULL, task_id TEXT NOT NULL UNIQUE, " + "payload TEXT NOT NULL, payload_sha256 TEXT NOT NULL, " + "created_at INTEGER NOT NULL)", + ), + "olympus_telegram_controls": ( + { + "operation_id", "action", "authorization_task_id", + "authorization_task_revision", "target_task_id", + "target_task_revision", "source_identity", "request_payload", + "payload_sha256", "verification_id", "result_status", + "effect_operation_id", "created_at", + }, + "CREATE TABLE olympus_telegram_controls (" + "operation_id TEXT PRIMARY KEY, action TEXT NOT NULL, " + "authorization_task_id TEXT NOT NULL, " + "authorization_task_revision INTEGER NOT NULL, " + "target_task_id TEXT NOT NULL, target_task_revision INTEGER NOT NULL, " + "source_identity TEXT NOT NULL, request_payload TEXT NOT NULL, " + "payload_sha256 TEXT NOT NULL, verification_id TEXT NOT NULL, " + "result_status TEXT, effect_operation_id TEXT, created_at INTEGER NOT NULL)", + ), + } + for table, (expected_columns, create_sql) in telegram_specs.items(): + columns = { + row["name"] for row in conn.execute(f"PRAGMA table_info({table})") + } + if not columns: + conn.execute(create_sql) + elif columns != expected_columns: + legacy = f"{table}_legacy_pre_v3" + with write_txn(conn): + if conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (legacy,), + ).fetchone() is not None: + raise sqlite3.IntegrityError( + f"kanban migration: preserved legacy table {legacy} already exists" + ) + conn.execute(f"ALTER TABLE {table} RENAME TO {legacy}") + failpoint = _OLYMPUS_TELEGRAM_MIGRATION_FAILPOINT + if callable(failpoint): + failpoint(table) + conn.execute(create_sql) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_olympus_telegram_control_target " + "ON olympus_telegram_controls(target_task_id, created_at)" + ) + # One-shot backfill: any task that is 'running' before runs existed # had its claim_lock / claim_expires / worker_pid on the task row. # Synthesize a matching task_runs row so subsequent end-run / heartbeat @@ -6782,7 +7195,7 @@ def _load_authorization_root( ): raise OlympusContextError( "olympus_authorization_root_foreign", - "Telegram authorization root is outside the target hierarchy", + "Telegram target is outside the selected authorization hierarchy", ) return { "id": str(row["id"]), @@ -6793,110 +7206,400 @@ def _load_authorization_root( } -def _authorization_root_snapshot( +def _telegram_target_identity( conn: sqlite3.Connection, *, - principal: OlympusMutationAuth, + authorization_task_id: str, + target_task_id: str, + action: str, target_context: dict[str, Any], -) -> Optional[dict[str, Any]]: - if principal.target_identity is None: - return None - return _load_authorization_root( +) -> tuple[dict[str, Any], dict[str, Any]]: + """Return the exact live Telegram authorization-root/target binding.""" + root = _load_authorization_root( conn, - root_id=principal.target_identity.get("authorization_subject_id", ""), + root_id=authorization_task_id, target_context=target_context, ) - - -def _insert_issued_permit( - conn: sqlite3.Connection, - *, - task_id: str, - subject_revision: int, - operation_id: str, - action: str, - capability: str, - auth_root_id: str, - auth_root_revision: int, - verification_id: str, - write_binding: Optional[Mapping[str, Any]] = None, -) -> None: - registry = getattr(conn, "_olympus_permit_registry", None) - if registry is None: - raise OlympusContextError( - "olympus_permit_registry_missing", - "process-local permit registry is unavailable", - ) - if bool(getattr(conn, "_olympus_external_txn", False)) \ - or int(getattr(conn, "_olympus_managed_txn_depth", 0)) <= 0: + target = conn.execute( + "SELECT id, assignee, status, record_revision, olympus_context " + "FROM tasks WHERE id = ?", + (str(target_task_id).strip(),), + ).fetchone() + if target is None or target["olympus_context"] is None: raise OlympusContextError( - "olympus_external_transaction_forbidden", - "governed permits require one Hermes-owned composing transaction", + "olympus_control_target_missing", + "Telegram target is missing or ungoverned", ) - encoded_binding = None - if write_binding is not None: + raw_target_context: Any = target["olympus_context"] + if isinstance(raw_target_context, str): try: - encoded_binding = json.dumps( - dict(write_binding), - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - except (TypeError, ValueError) as exc: + raw_target_context = json.loads(raw_target_context) + except (TypeError, ValueError, json.JSONDecodeError) as exc: raise OlympusContextError( - "olympus_permit_binding_invalid", - "process-local permit binding is not canonical JSON", + "olympus_context_invalid", + "Telegram target context is not valid JSON", ) from exc - issued = ( - int(subject_revision), str(operation_id), str(action), str(capability), - str(auth_root_id), int(auth_root_revision), str(verification_id), - encoded_binding, - ) - existing = registry.get(str(task_id)) - if existing is not None and existing != issued: - raise OlympusContextError( - "olympus_permit_scope_conflict", - "an issued permit cannot be retained or rebound", + target_normalized = normalize_olympus_context(raw_target_context) + if any( + target_normalized[key] != root["context"][key] + for key in ( + "goal_id", "program_id", "milestone_id", "mission_id", + "workstream_id", ) - registry[str(task_id)] = issued + ): + raise OlympusContextError( + "olympus_authorization_root_foreign", + "Telegram target is outside the selected authorization hierarchy", + ) + target_lease = target_normalized["lease"] + target_assignee = str(target["assignee"] or "") + if not ( + target_normalized["agent_id"] + == target_lease["agent_id"] + == target_lease["holder"] + == target_assignee + ): + raise OlympusContextError( + "olympus_agent_mismatch", + "Telegram target lease, delegated agent, and assignee do not match", + ) + authority = target_normalized["authority"] + identity = { + "authorization_subject_id": root["id"], + "authorization_subject_revision": root["record_revision"], + "authorization_subject_status": root["status"], + "control_action": action, + "task_id": str(target["id"]), + "task_record_revision": int(target["record_revision"]), + "goal_id": target_normalized["goal_id"], + "program_id": target_normalized["program_id"], + "milestone_id": target_normalized["milestone_id"], + "mission_id": target_normalized["mission_id"], + "workstream_id": target_normalized["workstream_id"], + "agent_id": target_normalized["agent_id"], + "assignee": target_assignee, + "status": str(target["status"]), + "authority_id": authority["authority_id"], + "authority_revision": authority["revision"], + "authority_status": authority["status"], + "authority_source": authority["source"], + "lease_id": target_lease["lease_id"], + "lease_revision": target_lease["revision"], + "lease_status": target_lease["status"], + "lease_source": target_lease["source"], + "lease_agent_id": target_lease["agent_id"], + "lease_holder": target_lease["holder"], + } + return identity, root -def _bind_issued_permit_write( +def _authorization_root_snapshot( conn: sqlite3.Connection, - task_id: str, - write_binding: Mapping[str, Any], -) -> None: - """Attach one exact immutable SQL row intent to an active permit.""" - registry = getattr(conn, "_olympus_permit_registry", None) - issued = None if registry is None else registry.get(str(task_id)) - if issued is None: - raise OlympusContextError( - "olympus_permit_registry_missing", - "receipt binding requires an active exact permit", - ) + *, + principal: OlympusMutationAuth, + target_context: dict[str, Any], + target_task_id: str, + target_record_revision: int, + target_status: str, + target_assignee: str, + action: str, +) -> Optional[dict[str, Any]]: + if principal.target_identity is None: + return None try: - encoded = json.dumps( - dict(write_binding), sort_keys=True, separators=(",", ":"), - allow_nan=False, + supplied = _require_exact_keys( + principal.target_identity, + frozenset(OLYMPUS_TARGET_IDENTITY_KEYS), + "target_identity", ) - except (TypeError, ValueError) as exc: + except AuthorityContractError as exc: raise OlympusContextError( - "olympus_permit_binding_invalid", - "receipt binding is not canonical JSON", + "olympus_target_identity_invalid", str(exc) ) from exc - if issued[7] is not None and issued[7] != encoded: + expected, root = _telegram_target_identity( + conn, + authorization_task_id=str(supplied["authorization_subject_id"]), + target_task_id=target_task_id, + action=action, + target_context=target_context, + ) + if ( + supplied != expected + or expected["task_record_revision"] != int(target_record_revision) + or expected["status"] != target_status + or expected["assignee"] != target_assignee + ): raise OlympusContextError( - "olympus_permit_scope_conflict", - "an issued permit cannot be rebound to another SQL row", + "olympus_target_identity_conflict", + "Telegram target identity is stale, foreign, or contradictory", ) - registry[str(task_id)] = (*issued[:7], encoded) + return root -def _issued_permit_row( - conn: sqlite3.Connection, task_id: str, -) -> Optional[dict[str, Any]]: - registry = getattr(conn, "_olympus_permit_registry", None) - issued = None if registry is None else registry.get(str(task_id)) +def olympus_telegram_auth( + conn: sqlite3.Connection, + *, + verifier: AuthorityVerifier, + source_identity: Mapping[str, Any], + authorization_task_id: str, + target_task_id: str, + action: str, + operation_id: str, +) -> OlympusMutationAuth: + """Build one canonical Telegram principal from authenticated/live state.""" + if action not in TELEGRAM_ACTION_CAPABILITIES: + raise OlympusContextError( + "olympus_telegram_action_invalid", + "Telegram action is not registered in the frozen authority profile", + ) + if not callable(verifier): + raise OlympusContextError( + "olympus_authority_verification_unavailable", + "canonical authority verifier is unavailable", + ) + try: + source = _require_exact_keys( + dict(source_identity), frozenset(OLYMPUS_SOURCE_IDENTITY_KEYS), + "source_identity", + ) + normalized_source = { + "platform": _strict_text(source["platform"], "source_identity.platform"), + "bot_id": _strict_text(source["bot_id"], "source_identity.bot_id"), + "profile": _strict_text(source["profile"], "source_identity.profile"), + "chat_id": _strict_text(source["chat_id"], "source_identity.chat_id"), + "thread_id": _strict_string( + source["thread_id"], "source_identity.thread_id", allow_empty=True, + ), + "user_id": _strict_text(source["user_id"], "source_identity.user_id"), + } + except (AuthorityContractError, TypeError, ValueError) as exc: + raise OlympusContextError( + "olympus_source_identity_invalid", str(exc) + ) from exc + if normalized_source["platform"] != "telegram": + raise OlympusContextError( + "olympus_source_identity_invalid", + "governed Telegram operations require platform=telegram", + ) + target = conn.execute( + "SELECT assignee, olympus_context FROM tasks WHERE id = ?", + (str(target_task_id).strip(),), + ).fetchone() + if target is None or target["olympus_context"] is None: + raise OlympusContextError( + "olympus_control_target_missing", + "Telegram target is missing or ungoverned", + ) + target_context = _require_current_olympus_context( + target["olympus_context"], assignee=target["assignee"] + ) + if target_context is None: + raise OlympusContextError( + "olympus_control_target_missing", + "Telegram target is missing or ungoverned", + ) + target_identity, root = _telegram_target_identity( + conn, + authorization_task_id=authorization_task_id, + target_task_id=target_task_id, + action=action, + target_context=target_context, + ) + return OlympusMutationAuth( + verifier=verifier, + principal_type="telegram_user", + principal_id=( + f"telegram:{normalized_source['bot_id']}:" + f"{normalized_source['user_id']}" + ), + principal_source=( + f"telegram-bot:{normalized_source['bot_id']}:" + f"profile:{normalized_source['profile']}" + ), + actor=str(root["assignee"]), + operation_id=_strict_text(operation_id, "operation_id"), + source_identity=normalized_source, + target_identity=target_identity, + ) + + +def olympus_service_auth( + conn: sqlite3.Connection, + *, + verifier: AuthorityVerifier, + dispatcher_instance_id: str, + actor: str, + operation_id: str, +) -> OlympusMutationAuth: + """Build the exact trusted service-dispatcher principal for this board.""" + if not callable(verifier): + raise OlympusContextError( + "olympus_authority_verification_unavailable", + "canonical authority verifier is unavailable", + ) + board_id = _connection_board_identity(conn) + dispatcher = _strict_text( + dispatcher_instance_id, "dispatcher_instance_id" + ) + return OlympusMutationAuth( + verifier=verifier, + principal_type="service", + principal_id=f"kanban-service-dispatcher:{board_id}:{dispatcher}", + principal_source=f"kanban-dispatcher:{board_id}:{dispatcher}", + actor=_strict_text(actor, "actor"), + operation_id=_strict_text(operation_id, "operation_id"), + ) + + +def verify_olympus_telegram_task( + conn: sqlite3.Connection, + *, + authorization_task_id: str, + target_task_id: str, + action: str, + verifier: AuthorityVerifier, + source_identity: Mapping[str, Any], + operation_id: str, +) -> dict[str, Any]: + """Freshly verify one exact Telegram action without mutating task state.""" + auth = olympus_telegram_auth( + conn, + verifier=verifier, + source_identity=source_identity, + authorization_task_id=authorization_task_id, + target_task_id=target_task_id, + action=action, + operation_id=operation_id, + ) + with olympus_mutation_scope(auth), write_txn(conn): + authorization, owns = _authorize_task_mutation( + conn, + target_task_id, + action=action, + capability=TELEGRAM_ACTION_CAPABILITIES[action], + auth=auth, + allow_inactive_target=action in TELEGRAM_EMERGENCY_ACTIONS, + ) + try: + row = conn.execute( + "SELECT id, status, record_revision, assignee, olympus_context " + "FROM tasks WHERE id = ?", + (target_task_id,), + ).fetchone() + if row is None: + raise OlympusContextError( + "olympus_task_missing", "verified Telegram target disappeared" + ) + current_context = _require_current_olympus_context( + row["olympus_context"], assignee=row["assignee"] + ) + if current_context is None: + raise OlympusContextError( + "olympus_control_target_missing", + "verified Telegram target became ungoverned", + ) + return { + "task_id": str(row["id"]), + "status": str(row["status"]), + "record_revision": int(row["record_revision"]), + "assignee": str(row["assignee"] or ""), + "context": current_context, + "verification": authorization["verification"], + "request": authorization["request"], + } + finally: + _release_task_mutation_permit(conn, target_task_id, owns) + + +def _insert_issued_permit( + conn: sqlite3.Connection, + *, + task_id: str, + subject_revision: int, + operation_id: str, + action: str, + capability: str, + auth_root_id: str, + auth_root_revision: int, + verification_id: str, + write_binding: Optional[Mapping[str, Any]] = None, +) -> None: + registry = getattr(conn, "_olympus_permit_registry", None) + if registry is None: + raise OlympusContextError( + "olympus_permit_registry_missing", + "process-local permit registry is unavailable", + ) + if bool(getattr(conn, "_olympus_external_txn", False)) \ + or int(getattr(conn, "_olympus_managed_txn_depth", 0)) <= 0: + raise OlympusContextError( + "olympus_external_transaction_forbidden", + "governed permits require one Hermes-owned composing transaction", + ) + encoded_binding = None + if write_binding is not None: + try: + encoded_binding = json.dumps( + dict(write_binding), + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + except (TypeError, ValueError) as exc: + raise OlympusContextError( + "olympus_permit_binding_invalid", + "process-local permit binding is not canonical JSON", + ) from exc + issued = ( + int(subject_revision), str(operation_id), str(action), str(capability), + str(auth_root_id), int(auth_root_revision), str(verification_id), + encoded_binding, + ) + existing = registry.get(str(task_id)) + if existing is not None and existing != issued: + raise OlympusContextError( + "olympus_permit_scope_conflict", + "an issued permit cannot be retained or rebound", + ) + registry[str(task_id)] = issued + + +def _bind_issued_permit_write( + conn: sqlite3.Connection, + task_id: str, + write_binding: Mapping[str, Any], +) -> None: + """Attach one exact immutable SQL row intent to an active permit.""" + registry = getattr(conn, "_olympus_permit_registry", None) + issued = None if registry is None else registry.get(str(task_id)) + if issued is None: + raise OlympusContextError( + "olympus_permit_registry_missing", + "receipt binding requires an active exact permit", + ) + try: + encoded = json.dumps( + dict(write_binding), sort_keys=True, separators=(",", ":"), + allow_nan=False, + ) + except (TypeError, ValueError) as exc: + raise OlympusContextError( + "olympus_permit_binding_invalid", + "receipt binding is not canonical JSON", + ) from exc + if issued[7] is not None and issued[7] != encoded: + raise OlympusContextError( + "olympus_permit_scope_conflict", + "an issued permit cannot be rebound to another SQL row", + ) + registry[str(task_id)] = (*issued[:7], encoded) + + +def _issued_permit_row( + conn: sqlite3.Connection, task_id: str, +) -> Optional[dict[str, Any]]: + registry = getattr(conn, "_olympus_permit_registry", None) + issued = None if registry is None else registry.get(str(task_id)) if issued is None: return None return { @@ -7361,6 +8064,11 @@ def _authorize_task_mutation( conn, principal=bound, target_context=normalized_context, + target_task_id=task_id, + target_record_revision=revision, + target_status=str(row["status"]), + target_assignee=str(row["assignee"] or ""), + action=action, ) target_assignee = str(row["assignee"] or "") if authorization_root is not None: @@ -8422,6 +9130,742 @@ def create_olympus_task( _release_task_mutation_permit(conn, task_id, True) +def _canonical_olympus_telegram_delivery_identity( + delivery_identity: Mapping[str, Any], + *, + source_identity: Mapping[str, Any], +) -> tuple[dict[str, Any], str, str]: + """Bind one Telegram delivery key to the authenticated bot/source tuple.""" + try: + raw = dict(delivery_identity) + base_keys = {"platform", "bot_id", "profile"} + if set(raw) == base_keys | {"update_id"}: + normalized = { + "platform": _strict_text( + raw["platform"], "delivery_identity.platform" + ), + "bot_id": _strict_text( + raw["bot_id"], "delivery_identity.bot_id" + ), + "profile": _strict_text( + raw["profile"], "delivery_identity.profile" + ), + "update_id": _strict_int( + raw["update_id"], "delivery_identity.update_id", minimum=0 + ), + } + elif set(raw) == base_keys | {"chat_id", "message_id"}: + normalized = { + "platform": _strict_text( + raw["platform"], "delivery_identity.platform" + ), + "bot_id": _strict_text( + raw["bot_id"], "delivery_identity.bot_id" + ), + "profile": _strict_text( + raw["profile"], "delivery_identity.profile" + ), + "chat_id": _strict_text( + raw["chat_id"], "delivery_identity.chat_id" + ), + "message_id": _strict_text( + raw["message_id"], "delivery_identity.message_id" + ), + } + else: + raise AuthorityContractError( + "delivery_identity must contain exactly bot/profile/platform " + "plus update_id or chat_id/message_id" + ) + except (AuthorityContractError, TypeError, ValueError) as exc: + raise OlympusContextError( + "olympus_delivery_identity_invalid", str(exc) + ) from exc + expected = { + "platform": source_identity["platform"], + "bot_id": source_identity["bot_id"], + "profile": source_identity["profile"], + } + if any(normalized[key] != value for key, value in expected.items()): + raise OlympusContextError( + "olympus_delivery_identity_conflict", + "delivery bot, profile, and platform must match the authenticated source", + ) + if "chat_id" in normalized \ + and normalized["chat_id"] != source_identity["chat_id"]: + raise OlympusContextError( + "olympus_delivery_identity_conflict", + "fallback delivery chat must match the authenticated source", + ) + encoded, digest = _canonical_json_record(normalized) + return normalized, encoded, digest + + +def create_olympus_telegram_task( + conn: sqlite3.Connection, + *, + telegram_auth: OlympusMutationAuth, + service_auth: OlympusMutationAuth, + delivery_key: str, + delivery_identity: Mapping[str, Any], + title: str, + body: Optional[str], + assignee: str, + created_by: Optional[str], + session_id: Optional[str], + platform: str, + chat_id: str, + thread_id: Optional[str], + user_id: Optional[str], + notifier_profile: Optional[str], + workspace_kind: str = "scratch", + workspace_path: Optional[str] = None, + branch_name: Optional[str] = None, + tenant: Optional[str] = None, + priority: int = 0, + max_runtime_seconds: Optional[int] = None, + skills: Optional[Iterable[str]] = None, + max_retries: Optional[int] = None, + goal_mode: bool = False, + goal_max_turns: Optional[int] = None, + board: Optional[str] = None, +) -> str: + """Atomically verify, create, journal, and subscribe one Telegram task.""" + if telegram_auth.source_identity is None or telegram_auth.target_identity is None: + raise OlympusContextError( + "olympus_telegram_principal_invalid", + "Telegram intake requires an authenticated source and exact target", + ) + target_identity = telegram_auth.target_identity + if target_identity.get("control_action") != "telegram-intake": + raise OlympusContextError( + "olympus_telegram_action_invalid", + "Telegram intake authorization is not bound to intake", + ) + authorization_task_id = str( + target_identity.get("authorization_subject_id", "") + ) + if target_identity.get("task_id") != authorization_task_id: + raise OlympusContextError( + "olympus_target_identity_conflict", + "Telegram intake must target the selected authorization root", + ) + if ( + service_auth.source_identity is not None + or service_auth.target_identity is not None + or service_auth.runtime_identity is not None + or service_auth.notifier_identity is not None + ): + raise OlympusContextError( + "olympus_service_principal_invalid", + "Telegram persistence requires the trusted service dispatcher", + ) + canonical_assignee = _canonical_assignee(assignee) + if canonical_assignee is None: + raise OlympusContextError( + "olympus_agent_missing", "Telegram intake requires a delegated agent" + ) + source = telegram_auth.source_identity + source_json, _ = _canonical_json_record(source) + normalized_delivery, delivery_identity_json, delivery_digest = ( + _canonical_olympus_telegram_delivery_identity( + delivery_identity, source_identity=source + ) + ) + expected_delivery_key = f"olympus-telegram:v3:{delivery_digest}" + if delivery_key != expected_delivery_key: + raise OlympusContextError( + "olympus_delivery_identity_conflict", + "delivery_key must be the SHA-256 of canonical delivery_identity", + ) + notification_identity = { + "platform": platform, + "chat_id": chat_id, + "thread_id": thread_id or "", + "user_id": user_id, + "notifier_profile": notifier_profile, + } + expected_notification = { + "platform": source["platform"], + "chat_id": source["chat_id"], + "thread_id": source["thread_id"], + "user_id": source["user_id"], + "notifier_profile": source["profile"], + } + if notification_identity != expected_notification: + raise OlympusContextError( + "olympus_notification_destination_conflict", + "notification destination must exactly match the authenticated " + "Telegram chat, thread, user, and profile", + ) + with write_txn(conn): + # A read-only preflight is deliberately a separate action. The intake + # permit below is exact-write-bound and cannot be issued until the + # generated task id and complete immutable payload are known. + preflight = olympus_telegram_auth( + conn, + verifier=telegram_auth.verifier, + source_identity=telegram_auth.source_identity, + authorization_task_id=authorization_task_id, + target_task_id=authorization_task_id, + action="telegram-status", + operation_id=f"{telegram_auth.operation_id}:preflight", + ) + with olympus_mutation_scope(preflight): + _, preflight_owns = _authorize_task_mutation( + conn, + authorization_task_id, + action="telegram-status", + capability=TELEGRAM_ACTION_CAPABILITIES["telegram-status"], + auth=preflight, + ) + _release_task_mutation_permit( + conn, authorization_task_id, preflight_owns + ) + root = conn.execute( + "SELECT assignee, record_revision, olympus_context FROM tasks WHERE id = ?", + (authorization_task_id,), + ).fetchone() + if root is None or root["olympus_context"] is None: + raise OlympusContextError( + "olympus_authorization_root_missing", + "Telegram authorization root disappeared before intake", + ) + root_context = _require_current_olympus_context( + root["olympus_context"], assignee=root["assignee"], + ) + assert root_context is not None + child_context = derive_olympus_child_context( + root_context, agent_id=canonical_assignee, + ) + if canonical_assignee != str(service_auth.actor or ""): + raise OlympusContextError( + "olympus_actor_mismatch", + "service dispatcher actor must match the delegated agent", + ) + existing = conn.execute( + "SELECT * FROM olympus_telegram_deliveries WHERE delivery_key = ?", + (delivery_key,), + ).fetchone() + if existing is not None: + task_id = str(existing["task_id"]) + else: + task_id = create_olympus_task( + conn, + olympus_context=child_context, + olympus_auth=service_auth, + title=title, + body=body, + assignee=canonical_assignee, + created_by=created_by, + workspace_kind=workspace_kind, + workspace_path=workspace_path, + branch_name=branch_name, + tenant=tenant, + priority=priority, + parents=(), + idempotency_key=delivery_key, + max_runtime_seconds=max_runtime_seconds, + skills=skills, + max_retries=max_retries, + goal_mode=goal_mode, + goal_max_turns=goal_max_turns, + initial_status="running", + session_id=session_id, + board=board, + ) + payload = { + "schema_version": "olympus-telegram-delivery/3", + "delivery_key": delivery_key, + "delivery_identity": normalized_delivery, + "source_identity": json.loads(source_json), + "authorization_task_id": authorization_task_id, + "authorization_task_revision": int(root["record_revision"]), + "mission_id": root_context["mission_id"], + "delegated_agent": canonical_assignee, + "task_id": task_id, + "title": title.strip(), + "body": body, + "created_by": created_by, + "session_id": session_id, + "workspace_kind": workspace_kind, + "workspace_path": workspace_path, + "branch_name": branch_name, + "tenant": tenant, + "priority": priority, + "max_runtime_seconds": max_runtime_seconds, + "skills": _normalize_task_skills(skills), + "max_retries": max_retries, + "goal_mode": bool(goal_mode), + "goal_max_turns": goal_max_turns, + "notification": notification_identity, + } + payload_json, payload_sha256 = _canonical_json_record(payload) + if existing is not None: + if ( + str(existing["payload"]) != payload_json + or str(existing["payload_sha256"]) != payload_sha256 + or str(existing["authorization_task_id"]) + != authorization_task_id + or int(existing["authorization_task_revision"]) + != int(root["record_revision"]) + ): + raise OlympusContextError( + "olympus_delivery_identity_conflict", + "Telegram delivery identity belongs to another immutable submission", + ) + else: + created_at = int(time.time()) + binding = { + "schema_version": TELEGRAM_DELIVERY_WRITE_SCHEMA, + "action": "telegram-intake", + "task_id": authorization_task_id, + "task_record_revision": int(root["record_revision"]), + "delivery_key": delivery_key, + "authorization_task_id": authorization_task_id, + "authorization_task_revision": int(root["record_revision"]), + "created_task_id": task_id, + "payload": payload_json, + "payload_sha256": payload_sha256, + "created_at": created_at, + } + with olympus_mutation_scope(telegram_auth): + authorization, owns = _authorize_task_mutation( + conn, + authorization_task_id, + action="telegram-intake", + capability=TELEGRAM_ACTION_CAPABILITIES["telegram-intake"], + auth=telegram_auth, + mutation_binding=binding, + ) + try: + conn.execute( + "INSERT INTO olympus_telegram_deliveries " + "(delivery_key,authorization_task_id," + "authorization_task_revision,task_id,payload," + "payload_sha256,created_at) VALUES (?,?,?,?,?,?,?)", + ( + delivery_key, authorization_task_id, + int(root["record_revision"]), task_id, payload_json, + payload_sha256, created_at, + ), + ) + _append_event( + conn, + authorization_task_id, + "olympus_telegram_intake", + { + "task_id": task_id, + "delivery_key": delivery_key, + "verification_id": authorization["verification"][ + "verification_id" + ], + }, + ) + finally: + _release_task_mutation_permit( + conn, authorization_task_id, owns + ) + # The same outer transaction contains delivery, task, create receipt, + # and destination subscription. A crash exposes all four or none. + add_notify_sub( + conn, + task_id=task_id, + platform=platform, + chat_id=chat_id, + thread_id=thread_id, + user_id=user_id, + notifier_profile=notifier_profile, + olympus_auth=service_auth, + ) + return task_id + + +def apply_olympus_telegram_control( + conn: sqlite3.Connection, + *, + telegram_auth: OlympusMutationAuth, + service_auth: OlympusMutationAuth, + target_task_id: str, + action: str, + operator_tag: str, +) -> dict[str, Any]: + """Authorize and apply one exact Telegram control without PID ownership.""" + wire_action = f"telegram-control:{action}" + if wire_action not in TELEGRAM_ACTION_CAPABILITIES: + raise OlympusContextError( + "olympus_control_invalid", "Telegram control action is invalid" + ) + operation_id = str(telegram_auth.operation_id or "") + if not operation_id.startswith("olympus-telegram-control:v3:"): + raise OlympusContextError( + "olympus_control_identity_invalid", + "Telegram control operation identity is not canonical v3", + ) + if ( + telegram_auth.source_identity is None + or telegram_auth.target_identity is None + or telegram_auth.target_identity.get("control_action") != wire_action + or telegram_auth.target_identity.get("task_id") != target_task_id + ): + raise OlympusContextError( + "olympus_target_identity_conflict", + "Telegram control is not bound to the exact requested target", + ) + authorization_task_id = str( + telegram_auth.target_identity["authorization_subject_id"] + ) + source_json, _ = _canonical_json_record(telegram_auth.source_identity) + operator_identity = _strict_text(operator_tag, "operator_tag") + + def _replay(existing: sqlite3.Row) -> dict[str, Any]: + """Validate and replay one immutable control receipt.""" + try: + prior = json.loads(str(existing["request_payload"])) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise OlympusContextError( + "olympus_control_receipt_invalid", + "Telegram control receipt is not valid JSON", + ) from exc + prior_target = prior.get("target_identity") + current_target = telegram_auth.target_identity + stable_target_keys = frozenset(OLYMPUS_TARGET_IDENTITY_KEYS) - { + "task_record_revision", "status", + } + stable_target_exact = ( + isinstance(prior_target, dict) + and isinstance(current_target, dict) + and all( + prior_target.get(key) == current_target.get(key) + for key in stable_target_keys + ) + ) + exact = ( + existing["action"] == wire_action + and existing["authorization_task_id"] == authorization_task_id + and existing["target_task_id"] == target_task_id + and existing["source_identity"] == source_json + and prior.get("schema_version") == "olympus-telegram-control/3" + and prior.get("operation_id") == operation_id + and prior.get("action") == wire_action + and prior.get("authorization_task_id") == authorization_task_id + and prior.get("authorization_task_revision") + == int(existing["authorization_task_revision"]) + and prior.get("target_task_id") == target_task_id + and prior.get("target_task_revision") + == int(existing["target_task_revision"]) + and prior.get("source_identity") == telegram_auth.source_identity + and stable_target_exact + and prior.get("operator_tag") == operator_identity + and prior.get("planned_status") == existing["result_status"] + and prior.get("effect_operation_id") + == existing["effect_operation_id"] + and hashlib.sha256( + str(existing["request_payload"]).encode("utf-8") + ).hexdigest() == existing["payload_sha256"] + ) + if not exact: + raise OlympusContextError( + "olympus_control_identity_conflict", + "control operation identity belongs to another immutable request", + ) + verified = verify_olympus_telegram_task( + conn, + authorization_task_id=authorization_task_id, + target_task_id=target_task_id, + action="telegram-status", + verifier=telegram_auth.verifier, + source_identity=telegram_auth.source_identity, + operation_id=f"{operation_id}:replay-status", + ) + return { + "operation_id": operation_id, + "status": verified["status"], + "replayed": True, + "effect_operation_id": existing["effect_operation_id"], + } + + existing = conn.execute( + "SELECT * FROM olympus_telegram_controls WHERE operation_id = ?", + (operation_id,), + ).fetchone() + if existing is not None: + return _replay(existing) + row = conn.execute( + "SELECT t.*, r.id AS run_id, r.launch_token, r.process_state, " + "r.worker_host_id, r.worker_boot_id, r.worker_pid AS run_worker_pid, " + "r.worker_start_token FROM tasks t LEFT JOIN task_runs r " + "ON r.id = t.current_run_id WHERE t.id = ?", + (target_task_id,), + ).fetchone() + if row is None or row["olympus_context"] is None: + raise OlympusContextError( + "olympus_control_target_missing", + "Telegram control target is missing or ungoverned", + ) + current_status = str(row["status"]) + valid_states = { + "pause": {"ready", "running"}, + "resume": {"blocked"}, + "interrupt": {"running"}, + "cancel": {"triage", "todo", "ready", "scheduled", "running", "blocked"}, + } + if current_status not in valid_states[action]: + raise OlympusContextError( + "olympus_control_state_invalid", + f"Telegram {action} is invalid from {current_status}", + ) + # Even emergency authority cannot invent a process effect from stale or + # revoked task state. The frozen v3 issuer may authorize containment, but + # Hermes still requires a current exact target before service execution. + _require_current_olympus_context( + row["olympus_context"], assignee=row["assignee"], + ) + effect_operation_id = None + if current_status == "running": + if ( + row["run_id"] is None + or row["process_state"] != "registered" + or not row["launch_token"] + or not row["worker_host_id"] + or not row["worker_boot_id"] + or not row["worker_start_token"] + or int(row["run_worker_pid"] or 0) <= 0 + ): + raise OlympusContextError( + "olympus_runtime_identity_stale", + "running control target lacks an exact registered process", + ) + result_status = "blocked" + effect_operation_id = f"{operation_id}:terminate-worker" + elif action == "resume": + undone = conn.execute( + "SELECT 1 FROM task_links l JOIN tasks p ON p.id = l.parent_id " + "WHERE l.child_id = ? AND p.status != 'done' LIMIT 1", + (target_task_id,), + ).fetchone() + result_status = "todo" if undone else "ready" + elif action == "cancel": + result_status = "archived" + else: + result_status = "blocked" + request_payload = { + "schema_version": "olympus-telegram-control/3", + "operation_id": operation_id, + "action": wire_action, + "authorization_task_id": authorization_task_id, + "authorization_task_revision": int( + telegram_auth.target_identity["authorization_subject_revision"] + ), + "target_task_id": target_task_id, + "target_task_revision": int(row["record_revision"]), + "source_identity": telegram_auth.source_identity, + "target_identity": telegram_auth.target_identity, + "operator_tag": operator_identity, + "planned_status": result_status, + "effect_operation_id": effect_operation_id, + } + request_json, request_sha256 = _canonical_json_record(request_payload) + created_at = int(time.time()) + binding = { + "schema_version": TELEGRAM_CONTROL_WRITE_SCHEMA, + "action": wire_action, + "task_id": target_task_id, + "task_record_revision": int(row["record_revision"]), + "operation_id": operation_id, + "authorization_task_id": authorization_task_id, + "authorization_task_revision": int( + telegram_auth.target_identity["authorization_subject_revision"] + ), + "source_identity": source_json, + "request_payload": request_json, + "payload_sha256": request_sha256, + "result_status": result_status, + "effect_operation_id": effect_operation_id, + "created_at": created_at, + } + with write_txn(conn), olympus_mutation_scope(telegram_auth): + # The optimistic read above avoids taking a write lock for ordinary + # replays. Recheck after BEGIN IMMEDIATE so simultaneous first + # deliveries serialize to one receipt and every loser replays it. + concurrent = conn.execute( + "SELECT * FROM olympus_telegram_controls WHERE operation_id = ?", + (operation_id,), + ).fetchone() + if concurrent is not None: + return _replay(concurrent) + if action in {"resume", "cancel"} and current_status != "running": + nonterminal_effect = conn.execute( + "SELECT 1 FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker' AND task_id=? " + "AND state NOT IN " + "('gone','failed','identity_mismatch','identity_unverified') " + "LIMIT 1", + (target_task_id,), + ).fetchone() + if nonterminal_effect is not None: + raise OlympusContextError( + "olympus_control_termination_in_progress", + "resume or non-running cancel cannot cross an active " + "worker-termination generation", + ) + authorization, owns = _authorize_task_mutation( + conn, + target_task_id, + action=wire_action, + capability=TELEGRAM_ACTION_CAPABILITIES[wire_action], + auth=telegram_auth, + mutation_binding=binding, + allow_inactive_target=wire_action in TELEGRAM_EMERGENCY_ACTIONS, + ) + try: + conn.execute( + "INSERT INTO olympus_telegram_controls " + "(operation_id,action,authorization_task_id," + "authorization_task_revision,target_task_id,target_task_revision," + "source_identity,request_payload,payload_sha256,verification_id," + "result_status,effect_operation_id,created_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + operation_id, wire_action, authorization_task_id, + int(telegram_auth.target_identity[ + "authorization_subject_revision" + ]), + target_task_id, int(row["record_revision"]), source_json, + request_json, request_sha256, + authorization["verification"]["verification_id"], + result_status, effect_operation_id, created_at, + ), + ) + _append_event( + conn, + target_task_id, + "olympus_telegram_control", + { + "action": action, + "operation_id": operation_id, + "planned_status": result_status, + "effect_operation_id": effect_operation_id, + "operator": operator_tag, + }, + ) + finally: + _release_task_mutation_permit(conn, target_task_id, owns) + service_operation = replace( + service_auth, + actor=str(row["assignee"] or ""), + operation_id=f"{operation_id}:service", + ) + if current_status == "running": + stage_worker_termination( + conn, + task_id=target_task_id, + run_id=int(row["run_id"]), + launch_token=str(row["launch_token"]), + process_identity=ProcessIdentity( + host_id=str(row["worker_host_id"]), + boot_id=str(row["worker_boot_id"]), + pid=int(row["run_worker_pid"]), + start_token=str(row["worker_start_token"]), + ), + operation_id=str(effect_operation_id), + reason=f"governed Telegram {action} by {operator_tag}", + source_identity={ + "schema_version": "olympus-telegram-control-source/3", + "operation_id": operation_id, + "verification_id": authorization["verification"][ + "verification_id" + ], + }, + outcome="reclaimed", + event_kind="termination_staged", + olympus_auth=service_operation, + ) + elif action == "pause": + # ``block`` is a worker-only action in the certified #16 + # principal contract. Telegram never impersonates that worker; + # the trusted service dispatcher performs the explicit direct + # status transition under its own separately verified permit. + if not set_task_status( + conn, target_task_id, "blocked", olympus_auth=service_operation + ): + raise OlympusContextError( + "olympus_control_cas_failed", "pause lost its exact state CAS" + ) + elif action == "resume": + if not unblock_task( + conn, target_task_id, olympus_auth=service_operation + ): + raise OlympusContextError( + "olympus_control_cas_failed", "resume lost its exact state CAS" + ) + elif action == "cancel": + if not archive_task( + conn, target_task_id, olympus_auth=service_operation + ): + raise OlympusContextError( + "olympus_control_cas_failed", "cancel lost its exact state CAS" + ) + observed = conn.execute( + "SELECT status FROM tasks WHERE id = ?", (target_task_id,) + ).fetchone() + if observed is None or observed["status"] != result_status: + raise OlympusContextError( + "olympus_control_result_conflict", + "control result does not match its immutable receipt", + ) + return { + "operation_id": operation_id, + "status": result_status, + "replayed": False, + "effect_operation_id": effect_operation_id, + } + + +def reconcile_olympus_telegram_controls( + conn: sqlite3.Connection, + *, + service_auth: OlympusMutationAuth, +) -> int: + """Finalize staged cancel only after #16's effect journal is terminal.""" + rows = conn.execute( + "SELECT c.operation_id,c.target_task_id,c.effect_operation_id," + "e.state,e.target_post_revision,t.status,t.record_revision,t.assignee " + "FROM olympus_telegram_controls c " + "JOIN tasks t ON t.id=c.target_task_id " + "JOIN kanban_effect_journal e ON e.effect_kind='terminate_worker' " + "AND e.operation_id=c.effect_operation_id " + "WHERE c.action='telegram-control:cancel' " + "AND c.effect_operation_id IS NOT NULL", + ).fetchall() + changed = 0 + for row in rows: + if row["status"] == "archived": + continue + # A successful SIGTERM call (``applied``) is not proof that the exact + # process exited. Only the dispatcher-owned birth-identity check may + # advance the effect to ``gone`` and permit a running cancel to archive. + if row["state"] != "gone" or row["status"] != "blocked": + continue + containment_revision = int(row["target_post_revision"] or 0) + if containment_revision < 1 \ + or int(row["record_revision"]) != containment_revision: + continue + auth = replace( + service_auth, + actor=str(row["assignee"] or ""), + operation_id=f"{row['operation_id']}:finalize-cancel", + ) + if archive_task( + conn, + str(row["target_task_id"]), + expected_record_revision=containment_revision, + olympus_auth=auth, + ): + changed += 1 + return changed + + def _find_missing_parents(conn: sqlite3.Connection, parents: Iterable[str]) -> list[str]: parents = list(parents) if not parents: @@ -10902,8 +12346,8 @@ def execute_worker_termination_effect( "UPDATE task_runs SET process_state = ? WHERE id = ? " "AND process_state = 'termination_pending'", ( - "terminal" - if state in {"applied", "gone"} + "terminal" if state == "gone" + else "termination_sent" if state == "applied" else "identity_unverified", row["run_id"], ), @@ -10914,6 +12358,108 @@ def execute_worker_termination_effect( return state +def confirm_applied_worker_termination_effects( + conn: sqlite3.Connection, + *, + olympus_auth: Optional[OlympusMutationAuth] = None, +) -> int: + """Confirm exact process exit after a previously applied SIGTERM. + + ``applied`` proves only that the signal call succeeded. It is not exit + evidence and therefore cannot authorize running-cancel archival. A later + dispatcher tick compares the current process birth identity with the + journaled exact target and advances only to ``gone`` (verified absent) or + ``identity_mismatch`` (PID now belongs to another process). + """ + rows = conn.execute( + "SELECT * FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker' AND state='applied' " + "ORDER BY id" + ).fetchall() + changed = 0 + for row in rows: + target = ProcessIdentity( + host_id=str(row["worker_host_id"] or ""), + boot_id=str(row["worker_boot_id"] or ""), + pid=int(row["worker_pid"] or 0), + start_token=str(row["worker_start_token"] or ""), + ) + live = read_process_identity(target.pid) + new_state = None + if live is None and not _pid_alive(target.pid): + new_state = "gone" + elif live is not None and live != target: + new_state = "identity_mismatch" + if new_state is None: + continue + try: + with write_txn(conn): + with _task_mutation_permit( + conn, + str(row["task_id"]), + action="settle_worker_termination", + capability=OLYMPUS_CAPABILITY_RECOVER, + auth=olympus_auth, + ): + cur = conn.execute( + "UPDATE kanban_effect_journal SET state=?, updated_at=? " + "WHERE id=? AND state='applied'", + (new_state, int(time.time()), int(row["id"])), + ) + if cur.rowcount: + conn.execute( + "UPDATE task_runs SET process_state=? WHERE id=? " + "AND process_state IN " + "('termination_pending','termination_sent','terminal')", + ( + "terminal" if new_state == "gone" + else "identity_unverified", + int(row["run_id"]), + ), + ) + changed += 1 + except OlympusContextError: + continue + return changed + + +def process_pending_worker_termination_effects( + conn: sqlite3.Connection, + *, + olympus_auth: Optional[OlympusMutationAuth] = None, + signal_fn=None, +) -> dict[str, int]: + """Dispatcher-owned executor for pending exact worker effects. + + Telegram never calls this function and never owns a PID. The production + dispatcher invokes it on every board tick/restart using its canonical + service principal. Each pending row is independently claimed by the #16 + effect CAS, rechecks the registered process birth identity, and signals at + most once. Applied signals are then checked for verified exit. + """ + pending = conn.execute( + "SELECT id FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker' AND state='pending' ORDER BY id" + ).fetchall() + executed = 0 + for row in pending: + try: + state = execute_worker_termination_effect( + conn, + int(row["id"]), + olympus_auth=olympus_auth, + signal_fn=signal_fn, + ) + except (KeyError, OlympusContextError): + continue + if state != "pending": + executed += 1 + confirmed = confirm_applied_worker_termination_effects( + conn, olympus_auth=olympus_auth + ) + return {"executed": executed, "confirmed": confirmed} + + def _stage_execute_governed_recovery( conn: sqlite3.Connection, *, @@ -13117,14 +14663,32 @@ def decompose_triage_task( @_guarded_task_mutation(action="archive", capability=OLYMPUS_CAPABILITY_ARCHIVE) -def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: +def archive_task( + conn: sqlite3.Connection, + task_id: str, + *, + expected_record_revision: Optional[int] = None, +) -> bool: with write_txn(conn): - cur = conn.execute( - "UPDATE tasks SET status = 'archived', " - " claim_lock = NULL, claim_expires = NULL, worker_pid = NULL " - "WHERE id = ? AND status != 'archived'", - (task_id,), - ) + if expected_record_revision is None: + cur = conn.execute( + "UPDATE tasks SET status = 'archived', " + " claim_lock = NULL, claim_expires = NULL, worker_pid = NULL " + "WHERE id = ? AND status != 'archived'", + (task_id,), + ) + else: + if isinstance(expected_record_revision, bool) \ + or not isinstance(expected_record_revision, int) \ + or expected_record_revision < 1: + raise ValueError("expected_record_revision must be a positive integer") + cur = conn.execute( + "UPDATE tasks SET status = 'archived', " + " claim_lock = NULL, claim_expires = NULL, worker_pid = NULL " + "WHERE id = ? AND status != 'archived' " + "AND record_revision = ?", + (task_id, expected_record_revision), + ) if cur.rowcount != 1: return False # If archive happened while a run was still in flight (e.g. user diff --git a/tests/gateway/test_background_command.py b/tests/gateway/test_background_command.py index a6c44f6f1189..492c13272e74 100644 --- a/tests/gateway/test_background_command.py +++ b/tests/gateway/test_background_command.py @@ -98,7 +98,9 @@ def capture_task(coro, *args, **kwargs): return mock_task with patch("gateway.run.asyncio.create_task", side_effect=capture_task): - event = _make_event(text="/background Summarize the top HN stories") + event = _make_event( + text="/background --ephemeral Summarize the top HN stories" + ) result = await runner._handle_background_command(event) assert "🔄" in result @@ -126,7 +128,7 @@ def capture_task(coro, *args, **kwargs): thread_id="20197", ) event = MessageEvent( - text="/background summarize", + text="/background --ephemeral summarize", source=source, message_id="463", reply_to_message_id="462", @@ -146,7 +148,7 @@ async def test_prompt_truncated_in_preview(self): long_prompt = "A" * 100 with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]): - event = _make_event(text=f"/background {long_prompt}") + event = _make_event(text=f"/background --ephemeral {long_prompt}") result = await runner._handle_background_command(event) assert "..." in result @@ -161,7 +163,7 @@ async def test_task_id_is_unique(self): with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]): for i in range(5): - event = _make_event(text=f"/background task {i}") + event = _make_event(text=f"/background --ephemeral task {i}") result = await runner._handle_background_command(event) # Extract task ID from result (format: "Task ID: bg_HHMMSS_hex") for line in result.split("\n"): @@ -178,7 +180,11 @@ async def test_works_across_platforms(self): runner = _make_runner() with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]): event = _make_event( - text="/background test task", + text=( + "/background --ephemeral test task" + if platform == Platform.TELEGRAM + else "/background test task" + ), platform=platform, ) result = await runner._handle_background_command(event) @@ -338,13 +344,21 @@ async def test_media_files_routed_by_type(self, monkeypatch): await runner._run_background_task("make stuff", source, "bg_test") mock_adapter.send_voice.assert_called_once() - assert mock_adapter.send_voice.call_args.kwargs["audio_path"] == _ogg + assert _os.path.samefile( + mock_adapter.send_voice.call_args.kwargs["audio_path"], _ogg + ) mock_adapter.send_video.assert_called_once() - assert mock_adapter.send_video.call_args.kwargs["video_path"] == _mp4 + assert _os.path.samefile( + mock_adapter.send_video.call_args.kwargs["video_path"], _mp4 + ) mock_adapter.send_image_file.assert_called_once() - assert mock_adapter.send_image_file.call_args.kwargs["image_path"] == _png + assert _os.path.samefile( + mock_adapter.send_image_file.call_args.kwargs["image_path"], _png + ) mock_adapter.send_document.assert_called_once() - assert mock_adapter.send_document.call_args.kwargs["file_path"] == _pdf + assert _os.path.samefile( + mock_adapter.send_document.call_args.kwargs["file_path"], _pdf + ) finally: import shutil as _shutil _shutil.rmtree(_tmpdir, ignore_errors=True) diff --git a/tests/gateway/test_kanban_watchers_mixin.py b/tests/gateway/test_kanban_watchers_mixin.py index d560dc0f0890..606afc164be7 100644 --- a/tests/gateway/test_kanban_watchers_mixin.py +++ b/tests/gateway/test_kanban_watchers_mixin.py @@ -70,5 +70,7 @@ def test_singleton_dispatcher_lock_is_exclusive(tmp_path): def test_dispatcher_reconciles_durable_restart_state_before_dispatch(): source = inspect.getsource(GatewayKanbanWatchersMixin._kanban_dispatcher_watcher) reconcile_at = source.index("_kb.reconcile_restart_state(") + execute_at = source.index("_kb.process_pending_worker_termination_effects(") + telegram_at = source.index("_kb.reconcile_olympus_telegram_controls(") dispatch_at = source.index("return _kb.dispatch_once(") - assert reconcile_at < dispatch_at + assert reconcile_at < execute_at < telegram_at < dispatch_at diff --git a/tests/gateway/test_olympus_telegram_router.py b/tests/gateway/test_olympus_telegram_router.py new file mode 100644 index 000000000000..cf89dca7ff7b --- /dev/null +++ b/tests/gateway/test_olympus_telegram_router.py @@ -0,0 +1,2154 @@ +"""Exact v3 authority, delivery, control, and restart contracts for Telegram.""" + +from __future__ import annotations + +import asyncio +import copy +import hashlib +import json +import sqlite3 +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform +from gateway.platforms.base import MessageEvent, MessageType +from gateway.session import SessionSource, SessionStore + + +MISSION_ID = "M-20260713-telegram-test" +BOT_ID = "9001" +PROFILE = "default" +DISPATCHER = "telegram-test-dispatcher" + + +def _allow_at(request: dict, now: float) -> dict: + from hermes_cli import kanban_db as kb + + emergency = request["action"] in kb.TELEGRAM_EMERGENCY_ACTIONS + subjects = [request["authorization_root"]] if emergency else [request["target"]] + if not emergency and request["authorization_root"] is not None: + subjects.append(request["authorization_root"]) + expiry = min( + value + for subject in subjects + for value in ( + subject["authority"]["expires_at"], + subject["lease"]["expires_at"], + ) + ) + return { + "schema_version": kb.AUTHORITY_VERIFICATION_SCHEMA, + "verification_id": f"verification:{request['request_id'].split(':', 1)[1]}", + "decision": "ALLOW", + "current": True, + "verified_at": now - 1, + "valid_until": min(now + 60, expiry), + "verified_principal": copy.deepcopy(request["principal"]), + "verified_actor": request["actor"], + "request_id": request["request_id"], + "request": copy.deepcopy(request), + "target_verification": { + "authority_current": not emergency, + "containment_target": emergency, + "subject": copy.deepcopy(request["target"]), + }, + "authorization_root_verification": ( + None + if request["authorization_root"] is None + else { + "authority_current": True, + "containment_target": False, + "subject": copy.deepcopy(request["authorization_root"]), + } + ), + } + + +def _allow(request: dict) -> dict: + return _allow_at(request, time.time()) + + +def _source( + *, + chat_id: str = "1001", + user_id: str = "operator-1", + thread_id: str | None = None, +): + return SessionSource( + platform=Platform.TELEGRAM, + chat_id=chat_id, + user_id=user_id, + thread_id=thread_id, + chat_type="dm", + ) + + +def _event( + text: str, + update_id: int, + *, + chat_id: str = "1001", + user_id: str = "operator-1", + thread_id: str | None = None, +) -> MessageEvent: + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=_source( + chat_id=chat_id, user_id=user_id, thread_id=thread_id + ), + message_id=str(update_id), + platform_update_id=update_id, + ) + + +def _context( + *, + mission_id: str = MISSION_ID, + agent_id: str = "coding", + authority_status: str = "ACTIVE", + lease_status: str = "ACTIVE", +) -> dict: + from hermes_cli import kanban_db as kb + + expires = int(time.time()) + 3600 + return { + "schema_version": 2, + "goal_id": f"goal:{mission_id}", + "program_id": f"program:{mission_id}", + "milestone_id": f"milestone:{mission_id}", + "mission_id": mission_id, + "workstream_id": f"workstream:{mission_id}", + "authority": { + "authority_id": f"authority:{mission_id}", + "status": authority_status, + "scope": [mission_id], + "capabilities": sorted(set(kb.KANBAN_TASK_ACTION_CAPABILITIES.values())), + "revision": 7, + "source": "issuer:test", + "expires_at": expires, + }, + "lease": { + "lease_id": f"lease:{mission_id}:{agent_id}", + "status": lease_status, + "mission_id": mission_id, + "agent_id": agent_id, + "holder": agent_id, + "repository": "hermes-agent", + "branch": "test/telegram", + "worktree": "/tmp/telegram-test", + "revision": 11, + "source": "issuer:test", + "expires_at": expires, + }, + "risk": "high", + "agent_id": agent_id, + "review_status": "pending", + "evidence_refs": ["evidence://telegram/test"], + } + + +def _source_identity( + *, + chat_id: str = "1001", + user_id: str = "operator-1", + thread_id: str | None = None, +) -> dict[str, str]: + return { + "platform": "telegram", + "bot_id": BOT_ID, + "profile": PROFILE, + "chat_id": chat_id, + "thread_id": thread_id or "", + "user_id": user_id, + } + + +def _digest(value) -> str: + raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(raw).hexdigest() + + +def _service_auth(conn, context: dict, operation_id: str): + from hermes_cli import kanban_db as kb + + return kb.olympus_service_auth( + conn, + verifier=_allow, + dispatcher_instance_id=DISPATCHER, + actor=context["agent_id"], + operation_id=operation_id, + ) + + +def _create_governed( + conn, + context: dict, + *, + title: str, + initial_status: str = "running", +) -> str: + from hermes_cli import kanban_db as kb + + return kb.create_olympus_task( + conn, + olympus_context=context, + olympus_auth=_service_auth(conn, context, f"create:{title}:{time.time_ns()}"), + title=title, + assignee=context["agent_id"], + created_by="test", + initial_status=initial_status, + ) + + +def _selection( + root_id: str, + context: dict, + *, + source_identity: dict[str, str] | None = None, +) -> dict: + authority = context["authority"] + lease = context["lease"] + return { + "schema_version": 2, + "board": "default", + "root_task_id": root_id, + "mission_id": context["mission_id"], + "agent_id": context["agent_id"], + "authority_id": authority["authority_id"], + "authority_revision": authority["revision"], + "authority_source": authority["source"], + "lease_id": lease["lease_id"], + "lease_revision": lease["revision"], + "lease_source": lease["source"], + "scope_digest": _digest(authority["scope"]), + "bot_id": BOT_ID, + "profile": PROFILE, + "caller_fingerprint": _digest(source_identity or _source_identity()), + } + + +def _runner(store: SessionStore): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = store.config + runner.session_store = store + runner.adapters = { + Platform.TELEGRAM: SimpleNamespace(_bot=SimpleNamespace(id=int(BOT_ID))) + } + runner._running_agents = {} + runner._running_agents_ts = {} + runner._busy_input_mode = "interrupt" + runner._busy_text_mode = "interrupt" + runner._busy_ack_ts = {} + runner._background_tasks = set() + runner._kanban_notifier_profile = PROFILE + runner._kanban_olympus_authority_verifier = _allow + runner._kanban_dispatcher_instance_id = DISPATCHER + runner._draining = False + runner._is_user_authorized = lambda source: True + runner._active_profile_name = lambda: PROFILE + return runner + + +@pytest.fixture() +def session_store(tmp_path, monkeypatch): + import hermes_state + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + config = GatewayConfig(sessions_dir=tmp_path / "sessions") + return SessionStore(sessions_dir=config.sessions_dir, config=config) + + +@pytest.fixture() +def governed_board(tmp_path, monkeypatch): + from hermes_cli import kanban_db as kb + + db_path = tmp_path / "kanban.db" + monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + context = _context() + conn = kb.connect(board="default") + try: + root_id = _create_governed(conn, context, title="Telegram root") + finally: + conn.close() + return SimpleNamespace(kb=kb, root_id=root_id, context=context) + + +def _set_selection( + store: SessionStore, + board, + *, + source: SessionSource | None = None, +) -> str: + source = source or _source() + entry = store.get_or_create_session(source) + identity = _source_identity( + chat_id=str(source.chat_id), + user_id=str(source.user_id), + thread_id=source.thread_id, + ) + assert store.set_olympus_selection( + entry.session_key, + _selection( + board.root_id, board.context, source_identity=identity + ), + ) + return entry.session_key + + +def _create_direct_telegram_delivery( + conn, + board, + *, + update_id: int = 700, + delivery_identity_overrides: dict | None = None, + delivery_key: str | None = None, + destination_overrides: dict | None = None, +): + kb = board.kb + source = _source_identity() + identity = { + "platform": "telegram", + "bot_id": BOT_ID, + "profile": PROFILE, + "update_id": update_id, + } + identity.update(delivery_identity_overrides or {}) + telegram_auth = kb.olympus_telegram_auth( + conn, + verifier=_allow, + source_identity=source, + authorization_task_id=board.root_id, + target_task_id=board.root_id, + action="telegram-intake", + operation_id=f"direct-intake:{update_id}", + ) + service_auth = kb.olympus_service_auth( + conn, + verifier=_allow, + dispatcher_instance_id=DISPATCHER, + actor=board.context["agent_id"], + operation_id=f"direct-intake:{update_id}:service", + ) + destination = { + "platform": source["platform"], + "chat_id": source["chat_id"], + "thread_id": source["thread_id"] or None, + "user_id": source["user_id"], + "notifier_profile": source["profile"], + } + destination.update(destination_overrides or {}) + return kb.create_olympus_telegram_task( + conn, + telegram_auth=telegram_auth, + service_auth=service_auth, + delivery_key=( + delivery_key + if delivery_key is not None + else f"olympus-telegram:v3:{_digest(identity)}" + ), + delivery_identity=identity, + title=f"direct delivery {update_id}", + body="exact destination", + assignee=board.context["agent_id"], + created_by="test", + session_id="session-direct", + board="default", + **destination, + ) + + +def _install_pre_v3_telegram_tables(conn, root_task_id: str) -> None: + conn.execute("DROP TABLE olympus_telegram_deliveries") + conn.execute("DROP TABLE olympus_telegram_controls") + conn.execute( + "CREATE TABLE olympus_telegram_deliveries (" + "delivery_key TEXT PRIMARY KEY, task_id TEXT NOT NULL UNIQUE, " + "immutable_context TEXT NOT NULL, created_at INTEGER NOT NULL)" + ) + conn.execute( + "INSERT INTO olympus_telegram_deliveries VALUES (?,?,?,?)", + ("olympus-telegram:v1:legacy", "t_legacy", "{}", 1), + ) + conn.execute( + "CREATE TABLE olympus_telegram_controls (" + "operation_id TEXT PRIMARY KEY, action TEXT NOT NULL, " + "authorization_task_id TEXT NOT NULL, target_task_id TEXT NOT NULL, " + "source_identity TEXT NOT NULL, target_identity TEXT NOT NULL, " + "verification_id TEXT NOT NULL, result_status TEXT NOT NULL, " + "termination_state TEXT NOT NULL, previous_worker_pid INTEGER, " + "previous_process_create_time REAL, previous_claim_lock TEXT, " + "termination_result TEXT, created_at INTEGER NOT NULL, " + "updated_at INTEGER NOT NULL)" + ) + conn.execute( + "INSERT INTO olympus_telegram_controls VALUES " + "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "olympus-telegram-control:v1:legacy", "interrupt", + root_task_id, "t_legacy", "{}", "{}", "v1", "blocked", + "pending", 4242, 1.0, "claim:v1", None, 1, 1, + ), + ) + + +def test_selection_survives_reload_and_rejects_legacy(session_store, governed_board): + key = _set_selection(session_store, governed_board) + reloaded = SessionStore(session_store.config.sessions_dir, session_store.config) + assert reloaded.get_olympus_selection(key) == _selection( + governed_board.root_id, governed_board.context + ) + with pytest.raises(ValueError, match="schema_version must be 2"): + reloaded.set_olympus_selection( + key, {"schema_version": 1, "board": "default", "root_task_id": "t_bad"} + ) + + +def test_restart_preserves_pre_v3_wip_journals_without_executing_them( + governed_board, +): + kb = governed_board.kb + db_path = kb.kanban_db_path(board="default") + conn = kb.connect(board="default") + try: + conn.execute("DROP TABLE olympus_telegram_deliveries") + conn.execute("DROP TABLE olympus_telegram_controls") + conn.execute( + "CREATE TABLE olympus_telegram_deliveries (" + "delivery_key TEXT PRIMARY KEY, task_id TEXT NOT NULL UNIQUE, " + "immutable_context TEXT NOT NULL, created_at INTEGER NOT NULL)" + ) + conn.execute( + "INSERT INTO olympus_telegram_deliveries VALUES (?,?,?,?)", + ("olympus-telegram:v1:legacy", "t_legacy", "{}", 1), + ) + conn.execute( + "CREATE TABLE olympus_telegram_controls (" + "operation_id TEXT PRIMARY KEY, action TEXT NOT NULL, " + "authorization_task_id TEXT NOT NULL, target_task_id TEXT NOT NULL, " + "source_identity TEXT NOT NULL, target_identity TEXT NOT NULL, " + "verification_id TEXT NOT NULL, result_status TEXT NOT NULL, " + "termination_state TEXT NOT NULL, previous_worker_pid INTEGER, " + "previous_process_create_time REAL, previous_claim_lock TEXT, " + "termination_result TEXT, created_at INTEGER NOT NULL, " + "updated_at INTEGER NOT NULL)" + ) + conn.execute( + "INSERT INTO olympus_telegram_controls VALUES " + "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "olympus-telegram-control:v1:legacy", "interrupt", + governed_board.root_id, "t_legacy", "{}", "{}", "v1", + "blocked", "pending", 4242, 1.0, "claim:v1", None, 1, 1, + ), + ) + finally: + conn.close() + + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + reopened = kb.connect(board="default") + try: + assert reopened.execute( + "SELECT immutable_context FROM " + "olympus_telegram_deliveries_legacy_pre_v3 " + "WHERE delivery_key='olympus-telegram:v1:legacy'" + ).fetchone()[0] == "{}" + assert reopened.execute( + "SELECT previous_worker_pid FROM " + "olympus_telegram_controls_legacy_pre_v3 " + "WHERE operation_id='olympus-telegram-control:v1:legacy'" + ).fetchone()[0] == 4242 + assert reopened.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 0 + assert reopened.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 0 + finally: + reopened.close() + + +@pytest.mark.parametrize( + "failed_table", + ("olympus_telegram_deliveries", "olympus_telegram_controls"), +) +def test_interrupted_pre_v3_migration_rolls_back_then_reopens_idempotently( + governed_board, failed_table +): + kb = governed_board.kb + db_path = kb.kanban_db_path(board="default") + conn = kb.connect(board="default") + try: + _install_pre_v3_telegram_tables(conn, governed_board.root_id) + finally: + conn.close() + + def interrupt(table: str) -> None: + if table == failed_table: + raise RuntimeError(f"migration crash:{table}") + + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + kb._OLYMPUS_TELEGRAM_MIGRATION_FAILPOINT = interrupt + try: + with pytest.raises(RuntimeError, match="migration crash"): + kb.connect(board="default") + finally: + kb._OLYMPUS_TELEGRAM_MIGRATION_FAILPOINT = None + raw = sqlite3.connect(db_path) + try: + failed_columns = { + row[1] for row in raw.execute(f"PRAGMA table_info({failed_table})") + } + assert ( + "immutable_context" in failed_columns + if failed_table == "olympus_telegram_deliveries" + else "termination_state" in failed_columns + ) + assert raw.execute( + f"SELECT count(*) FROM {failed_table}" + ).fetchone()[0] == 1 + assert raw.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (f"{failed_table}_legacy_pre_v3",), + ).fetchone() is None + finally: + raw.close() + + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + migrated = kb.connect(board="default") + migrated.close() + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + reopened = kb.connect(board="default") + try: + assert reopened.execute( + "SELECT count(*) FROM olympus_telegram_deliveries_legacy_pre_v3" + ).fetchone()[0] == 1 + assert reopened.execute( + "SELECT count(*) FROM olympus_telegram_controls_legacy_pre_v3" + ).fetchone()[0] == 1 + assert reopened.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 0 + assert reopened.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 0 + with pytest.raises(sqlite3.IntegrityError, match="authority"): + reopened.execute( + "INSERT INTO olympus_telegram_deliveries VALUES " + "(?,?,?,?,?,?,?)", + ( + "olympus-telegram:v3:" + "8" * 64, + governed_board.root_id, + 1, + "t_88888888", + "{}", + "8" * 64, + 8, + ), + ) + reopened.rollback() + with pytest.raises(sqlite3.IntegrityError, match="authority"): + reopened.execute( + "INSERT INTO olympus_telegram_controls VALUES " + "(?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "olympus-telegram-control:v3:" + "9" * 64, + "telegram-control:pause", + governed_board.root_id, + 1, + governed_board.root_id, + 1, + "{}", + "{}", + "9" * 64, + "forged", + "blocked", + None, + 9, + ), + ) + reopened.rollback() + + _create_direct_telegram_delivery( + reopened, governed_board, update_id=8090 + ) + target_id = _create_governed( + reopened, + governed_board.context, + title=f"migration-guard-{failed_table}", + ) + control_auth = kb.olympus_telegram_auth( + reopened, + verifier=_allow, + source_identity=_source_identity(), + authorization_task_id=governed_board.root_id, + target_task_id=target_id, + action="telegram-control:pause", + operation_id="olympus-telegram-control:v3:" + "a" * 64, + ) + kb.apply_olympus_telegram_control( + reopened, + telegram_auth=control_auth, + service_auth=_service_auth( + reopened, governed_board.context, "migration-guard:service" + ), + target_task_id=target_id, + action="pause", + operator_tag="telegram:migration-guard", + ) + delivery_key = reopened.execute( + "SELECT delivery_key FROM olympus_telegram_deliveries" + ).fetchone()[0] + control_id = reopened.execute( + "SELECT operation_id FROM olympus_telegram_controls" + ).fetchone()[0] + for statement, params in ( + ( + "UPDATE olympus_telegram_deliveries SET payload='{}' " + "WHERE delivery_key=?", + (delivery_key,), + ), + ( + "DELETE FROM olympus_telegram_deliveries WHERE delivery_key=?", + (delivery_key,), + ), + ( + "UPDATE olympus_telegram_controls SET result_status='ready' " + "WHERE operation_id=?", + (control_id,), + ), + ( + "DELETE FROM olympus_telegram_controls WHERE operation_id=?", + (control_id,), + ), + ): + with pytest.raises(sqlite3.DatabaseError): + reopened.execute(statement, params) + reopened.rollback() + finally: + reopened.close() + + +@pytest.mark.parametrize( + "collision_table", + ("olympus_telegram_deliveries", "olympus_telegram_controls"), +) +def test_pre_v3_preservation_name_collision_fails_closed_without_data_loss( + governed_board, collision_table +): + kb = governed_board.kb + db_path = kb.kanban_db_path(board="default") + conn = kb.connect(board="default") + try: + _install_pre_v3_telegram_tables(conn, governed_board.root_id) + legacy = f"{collision_table}_legacy_pre_v3" + conn.execute(f"CREATE TABLE {legacy} (marker TEXT NOT NULL)") + conn.execute( + f"INSERT INTO {legacy} VALUES ('collision-evidence')" + ) + finally: + conn.close() + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + with pytest.raises(sqlite3.IntegrityError, match="already exists"): + kb.connect(board="default") + raw = sqlite3.connect(db_path) + try: + current_columns = { + row[1] for row in raw.execute( + f"PRAGMA table_info({collision_table})" + ) + } + assert ( + "immutable_context" in current_columns + if collision_table == "olympus_telegram_deliveries" + else "termination_state" in current_columns + ) + assert raw.execute( + f"SELECT count(*) FROM {collision_table}" + ).fetchone()[0] == 1 + assert raw.execute( + f"SELECT marker FROM {collision_table}_legacy_pre_v3" + ).fetchone()[0] == "collision-evidence" + other = ( + "olympus_telegram_controls" + if collision_table == "olympus_telegram_deliveries" + else "olympus_telegram_deliveries_legacy_pre_v3" + ) + assert raw.execute(f"SELECT count(*) FROM {other}").fetchone()[0] == 1 + finally: + raw.close() + + +@pytest.mark.asyncio +async def test_select_status_clear_require_v3_verifier(session_store, governed_board): + runner = _runner(session_store) + selected = await runner._handle_olympus_command( + _event(f"/olympus select {governed_board.root_id}", 1) + ) + assert "durable intake selected" in selected + assert "authority current" in await runner._handle_olympus_command( + _event("/olympus status", 2) + ) + runner._kanban_olympus_authority_verifier = None + denied = await runner._handle_olympus_command(_event("/olympus clear", 3)) + assert "canonical authority verifier is unavailable" in denied + assert runner._olympus_selection_for_event(_event("status", 4)) is not None + + +@pytest.mark.asyncio +async def test_clear_cas_preserves_concurrent_replacement( + session_store, governed_board, monkeypatch +): + runner = _runner(session_store) + key = _set_selection(session_store, governed_board) + captured = session_store.get_olympus_selection(key) + replacement = dict(captured) + replacement["root_task_id"] = "t_deadbeef" + original_verify = runner._verify_olympus_root + + def replace_during_verification(*args, **kwargs): + result = original_verify(*args, **kwargs) + assert session_store.set_olympus_selection(key, replacement) + return result + + monkeypatch.setattr( + runner, "_verify_olympus_root", replace_during_verification + ) + result = await runner._handle_olympus_command(_event("/olympus clear", 5)) + assert "selection changed" in result + assert session_store.get_olympus_selection(key) == replacement + + +@pytest.mark.asyncio +async def test_source_and_selection_identity_conflicts_fail_closed( + session_store, governed_board +): + runner = _runner(session_store) + _set_selection(session_store, governed_board) + runner.adapters[Platform.TELEGRAM]._bot.id = 9002 + assert "wrong-source" in await runner._route_olympus_telegram_intake( + _event("job", 10) + ) + runner.adapters[Platform.TELEGRAM]._bot.id = int(BOT_ID) + with pytest.raises(ValueError, match="caller-conflicted"): + runner._validate_olympus_selection_binding( + _selection(governed_board.root_id, governed_board.context), + governed_board.context, + _source_identity(user_id="foreign"), + ) + + +@pytest.mark.parametrize( + ("identity_overrides", "destination_overrides", "forced_key"), + ( + ({"bot_id": "foreign-bot"}, None, None), + ({"profile": "foreign-profile"}, None, None), + ({"platform": "webhook"}, None, None), + (None, {"chat_id": "unrelated-chat"}, None), + (None, {"thread_id": "unrelated-thread"}, None), + (None, {"user_id": "unrelated-user"}, None), + (None, {"notifier_profile": "unrelated-profile"}, None), + (None, None, "olympus-telegram:v3:" + "0" * 64), + ), +) +def test_delivery_and_destination_containment_matrix( + governed_board, identity_overrides, destination_overrides, forced_key +): + conn = governed_board.kb.connect(board="default") + try: + with pytest.raises( + governed_board.kb.OlympusContextError, + match="delivery|notification|authenticated", + ): + _create_direct_telegram_delivery( + conn, + governed_board, + update_id=701, + delivery_identity_overrides=identity_overrides, + destination_overrides=destination_overrides, + delivery_key=forced_key, + ) + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT count(*) FROM tasks WHERE id != ?", (governed_board.root_id,) + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT count(*) FROM kanban_notify_subs" + ).fetchone()[0] == 0 + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_duplicate_concurrent_delivery_is_one_atomic_task_and_subscription( + session_store, governed_board +): + runner = _runner(session_store) + _set_selection(session_store, governed_board) + event = _event("Investigate durable routing", 44) + results = await asyncio.gather( + *(runner._route_olympus_telegram_intake(event) for _ in range(6)) + ) + assert len(set(results)) == 1 + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 1 + assert conn.execute( + "SELECT count(*) FROM tasks WHERE id != ?", (governed_board.root_id,) + ).fetchone()[0] == 1 + assert conn.execute( + "SELECT count(*) FROM kanban_notify_subs" + ).fetchone()[0] == 1 + assert conn.execute( + "SELECT count(*) FROM kanban_olympus_create_receipts" + ).fetchone()[0] == 1 + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_delivery_payload_collision_is_denied(session_store, governed_board): + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert (await runner._route_olympus_telegram_intake( + _event("first", 45) + )).startswith("Queued") + denied = await runner._route_olympus_telegram_intake(_event("changed", 45)) + assert "immutable" in denied or "payload" in denied + + +@pytest.mark.asyncio +async def test_subscription_failure_rolls_back_delivery_and_task( + session_store, governed_board, monkeypatch +): + runner = _runner(session_store) + _set_selection(session_store, governed_board) + original_add = governed_board.kb.add_notify_sub + monkeypatch.setattr( + governed_board.kb, + "add_notify_sub", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("subscription crash")), + ) + denied = await runner._route_olympus_telegram_intake(_event("atomic", 46)) + assert "subscription crash" in denied + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT count(*) FROM tasks WHERE id != ?", (governed_board.root_id,) + ).fetchone()[0] == 0 + finally: + conn.close() + monkeypatch.setattr(governed_board.kb, "add_notify_sub", original_add) + assert (await runner._route_olympus_telegram_intake( + _event("atomic", 46) + )).startswith("Queued") + reopened = governed_board.kb.connect(board="default") + try: + assert reopened.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 1 + assert reopened.execute( + "SELECT count(*) FROM kanban_olympus_create_receipts" + ).fetchone()[0] == 1 + assert reopened.execute( + "SELECT count(*) FROM kanban_notify_subs" + ).fetchone()[0] == 1 + finally: + reopened.close() + + +@pytest.mark.asyncio +async def test_three_then_six_durable_submissions_survive_reopen( + session_store, governed_board +): + runner = _runner(session_store) + key = _set_selection(session_store, governed_board) + first = await asyncio.gather( + *(runner._route_olympus_telegram_intake(_event(f"job {i}", 100 + i)) for i in range(3)) + ) + assert all(result.startswith("Queued") for result in first) + restarted = SessionStore(session_store.config.sessions_dir, session_store.config) + assert restarted.get_olympus_selection(key) is not None + runner = _runner(restarted) + second = await asyncio.gather( + *( + runner._route_olympus_telegram_intake( + _event(f"job {i}", 100 + i) + ) + for i in range(3, 9) + ) + ) + assert all(result.startswith("Queued") for result in second) + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 9 + assert conn.execute( + "SELECT count(DISTINCT delivery_key) " + "FROM olympus_telegram_deliveries" + ).fetchone()[0] == 9 + assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + finally: + conn.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("action", "starting", "expected"), + [("pause", "ready", "blocked"), ("resume", "blocked", "ready"), ("cancel", "ready", "archived")], +) +async def test_nonrunning_controls_are_exactly_once( + session_store, governed_board, action, starting, expected +): + conn = governed_board.kb.connect(board="default") + try: + target_id = _create_governed( + conn, + governed_board.context, + title=f"control-{action}", + initial_status="blocked" if starting == "blocked" else "running", + ) + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + event = _event(f"/olympus {action} {target_id}", 200) + first = await runner._handle_olympus_command(event) + second = await runner._handle_olympus_command(event) + assert first == second + assert f"status is `{expected}`" in first + conn = governed_board.kb.connect(board="default") + try: + assert governed_board.kb.get_task(conn, target_id).status == expected + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 1 + events = [ + event + for event in governed_board.kb.list_events(conn, target_id) + if event.kind == "olympus_telegram_control" + ] + assert len(events) == 1 + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_concurrent_duplicate_control_serializes_to_one_receipt( + session_store, governed_board +): + conn = governed_board.kb.connect(board="default") + try: + target_id = _create_governed( + conn, governed_board.context, title="concurrent-pause" + ) + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + event = _event(f"/olympus pause {target_id}", 201) + results = await asyncio.gather( + *(runner._handle_olympus_command(event) for _ in range(6)) + ) + assert len(set(results)) == 1 + assert "status is `blocked`" in results[0] + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 1 + assert governed_board.kb.get_task(conn, target_id).status == "blocked" + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_control_operation_id_collision_cannot_change_action_or_target( + session_store, governed_board +): + conn = governed_board.kb.connect(board="default") + try: + first_id = _create_governed( + conn, governed_board.context, title="collision-first" + ) + second_id = _create_governed( + conn, governed_board.context, title="collision-second" + ) + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus pause {first_id}", 202) + ) + denied = await runner._handle_olympus_command( + _event(f"/olympus cancel {second_id}", 202) + ) + assert "identity belongs to another" in denied + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 1 + assert governed_board.kb.get_task(conn, first_id).status == "blocked" + assert governed_board.kb.get_task(conn, second_id).status == "ready" + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_control_operation_collision_changed_action_only( + session_store, governed_board +): + conn = governed_board.kb.connect(board="default") + try: + target_id = _create_governed( + conn, governed_board.context, title="collision-action" + ) + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus pause {target_id}", 2021) + ) + denied = await runner._handle_olympus_command( + _event(f"/olympus resume {target_id}", 2021) + ) + assert "identity belongs to another" in denied + + +@pytest.mark.asyncio +async def test_control_operation_collision_changed_target_only( + session_store, governed_board +): + conn = governed_board.kb.connect(board="default") + try: + first_id = _create_governed( + conn, governed_board.context, title="collision-target-first" + ) + second_id = _create_governed( + conn, governed_board.context, title="collision-target-second" + ) + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus pause {first_id}", 2022) + ) + denied = await runner._handle_olympus_command( + _event(f"/olympus pause {second_id}", 2022) + ) + assert "identity belongs to another" in denied + conn = governed_board.kb.connect(board="default") + try: + assert governed_board.kb.get_task(conn, second_id).status == "ready" + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_control_operation_collision_changed_source_only( + session_store, governed_board +): + conn = governed_board.kb.connect(board="default") + try: + target_id = _create_governed( + conn, governed_board.context, title="collision-source" + ) + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus pause {target_id}", 2023) + ) + foreign_source = _source(user_id="operator-2") + _set_selection(session_store, governed_board, source=foreign_source) + denied = await runner._handle_olympus_command( + _event(f"/olympus pause {target_id}", 2023, user_id="operator-2") + ) + assert "identity belongs to another" in denied + + +@pytest.mark.asyncio +async def test_control_operation_collision_changed_payload_only( + session_store, governed_board +): + conn = governed_board.kb.connect(board="default") + try: + target_id = _create_governed( + conn, governed_board.context, title="collision-payload" + ) + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + update_id = 2024 + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus pause {target_id}", update_id) + ) + conn = governed_board.kb.connect(board="default") + try: + operation_id = ( + "olympus-telegram-control:v3:" + + _digest( + { + "platform": "telegram", + "bot_id": BOT_ID, + "profile": PROFILE, + "update_id": update_id, + } + ) + ) + telegram_auth = governed_board.kb.olympus_telegram_auth( + conn, + verifier=_allow, + source_identity=_source_identity(), + authorization_task_id=governed_board.root_id, + target_task_id=target_id, + action="telegram-control:pause", + operation_id=operation_id, + ) + with pytest.raises( + governed_board.kb.OlympusContextError, + match="identity belongs to another", + ): + governed_board.kb.apply_olympus_telegram_control( + conn, + telegram_auth=telegram_auth, + service_auth=_service_auth( + conn, governed_board.context, "collision-payload:service" + ), + target_task_id=target_id, + action="pause", + operator_tag="telegram:changed-control-payload", + ) + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_concurrent_interrupt_cancel_operation_collision_admits_one( + session_store, governed_board, monkeypatch +): + target_id, _ = _register_running_target( + governed_board, monkeypatch, title="collision-interrupt-cancel" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + results = await asyncio.gather( + runner._handle_olympus_command( + _event(f"/olympus interrupt {target_id}", 2025) + ), + runner._handle_olympus_command( + _event(f"/olympus cancel {target_id}", 2025) + ), + ) + assert sum("status is `blocked`" in result for result in results) == 1 + assert sum("identity belongs to another" in result for result in results) == 1 + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 1 + assert conn.execute( + "SELECT count(*) FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone()[0] == 1 + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_nonrunning_control_fault_rolls_back_receipt_then_retry_succeeds( + session_store, governed_board, monkeypatch +): + conn = governed_board.kb.connect(board="default") + try: + target_id = _create_governed( + conn, governed_board.context, title="fault-nonrunning" + ) + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + event = _event(f"/olympus pause {target_id}", 203) + original = governed_board.kb.set_task_status + monkeypatch.setattr( + governed_board.kb, + "set_task_status", + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("nonrunning mutation crash") + ), + ) + assert "nonrunning mutation crash" in await runner._handle_olympus_command(event) + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 0 + assert governed_board.kb.get_task(conn, target_id).status == "ready" + finally: + conn.close() + monkeypatch.setattr(governed_board.kb, "set_task_status", original) + assert "status is `blocked`" in await runner._handle_olympus_command(event) + + +@pytest.mark.asyncio +async def test_running_control_stage_fault_rolls_back_then_retry_succeeds( + session_store, governed_board, monkeypatch +): + target_id, _ = _register_running_target( + governed_board, monkeypatch, title="fault-effect-stage" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + event = _event(f"/olympus interrupt {target_id}", 204) + + def fail_after_stage(stage: str) -> None: + if stage == "after_stage": + raise RuntimeError("effect stage crash") + + governed_board.kb._OLYMPUS_EFFECT_EXECUTION_FAILPOINT = fail_after_stage + try: + assert "effect stage crash" in await runner._handle_olympus_command(event) + finally: + governed_board.kb._OLYMPUS_EFFECT_EXECUTION_FAILPOINT = None + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT count(*) FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone()[0] == 0 + assert governed_board.kb.get_task(conn, target_id).status == "running" + finally: + conn.close() + assert "status is `blocked`" in await runner._handle_olympus_command(event) + reopened = governed_board.kb.connect(board="default") + try: + assert reopened.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 1 + assert reopened.execute( + "SELECT state FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone()[0] == "pending" + finally: + reopened.close() + + +def _register_running_target( + board, + monkeypatch, + *, + title: str, + context: dict | None = None, + pid: int = 4242, +): + kb = board.kb + context = context or board.context + conn = kb.connect(board="default") + target_id = _create_governed(conn, context, title=title) + run = kb.reserve_worker_run( + conn, + target_id, + claimer=kb._claimer_id(), + olympus_auth=_service_auth(conn, context, f"{title}:claim"), + ) + assert run is not None and run.launch_token + assert kb.mark_worker_workspace_ready( + conn, + task_id=target_id, + run_id=run.id, + launch_token=run.launch_token, + workspace_snapshot={"board_id": kb._connection_board_identity(conn)}, + olympus_auth=_service_auth(conn, context, f"{title}:workspace"), + ) + assert kb.mark_worker_starting( + conn, + task_id=target_id, + run_id=run.id, + launch_token=run.launch_token, + olympus_auth=_service_auth(conn, context, f"{title}:starting"), + ) + identity = kb.ProcessIdentity( + "host:test", "boot:test", pid, f"birth:{title}" + ) + monkeypatch.setattr( + kb, + "read_process_identity", + lambda pid: identity if int(pid) == identity.pid else None, + ) + assert kb.register_worker_process( + conn, + task_id=target_id, + run_id=run.id, + launch_token=run.launch_token, + process_identity=identity, + dispatcher_instance_id=DISPATCHER, + olympus_auth=_service_auth(conn, context, f"{title}:register"), + ) + conn.close() + return target_id, identity + + +@pytest.mark.asyncio +async def test_interrupt_stages_certified_effect_without_signaling( + session_store, governed_board, monkeypatch +): + target_id, _ = _register_running_target( + governed_board, monkeypatch, title="interrupt" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + result = await runner._handle_olympus_command( + _event(f"/olympus interrupt {target_id}", 210) + ) + assert "status is `blocked`" in result + conn = governed_board.kb.connect(board="default") + try: + effect = conn.execute( + "SELECT effect_kind,state FROM kanban_effect_journal" + ).fetchone() + assert dict(effect) == {"effect_kind": "terminate_worker", "state": "pending"} + control = conn.execute( + "SELECT effect_operation_id FROM olympus_telegram_controls" + ).fetchone() + assert control["effect_operation_id"] + finally: + conn.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("next_action", ("resume", "cancel")) +async def test_interrupt_denies_resume_or_nonrunning_cancel_while_effect_active( + session_store, governed_board, monkeypatch, next_action +): + target_id, _ = _register_running_target( + governed_board, monkeypatch, title=f"interrupt-then-{next_action}" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus interrupt {target_id}", 2110) + ) + denied = await runner._handle_olympus_command( + _event(f"/olympus {next_action} {target_id}", 2111) + ) + assert "active worker-termination generation" in denied + conn = governed_board.kb.connect(board="default") + try: + assert governed_board.kb.get_task(conn, target_id).status == "blocked" + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 1 + assert conn.execute( + "SELECT state FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone()[0] == "pending" + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_registered_process_pause_preserves_unrelated_running_process( + session_store, governed_board, monkeypatch +): + target_id, target_identity = _register_running_target( + governed_board, monkeypatch, title="registered-pause", pid=4242 + ) + unrelated_id, unrelated_identity = _register_running_target( + governed_board, monkeypatch, title="unrelated-running", pid=4343 + ) + identities = { + target_identity.pid: target_identity, + unrelated_identity.pid: unrelated_identity, + } + monkeypatch.setattr( + governed_board.kb, + "read_process_identity", + lambda pid: identities.get(int(pid)), + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus pause {target_id}", 2112) + ) + signaled = [] + conn = governed_board.kb.connect(board="default") + try: + result = governed_board.kb.process_pending_worker_termination_effects( + conn, + olympus_auth=_service_auth( + conn, governed_board.context, "registered-pause:dispatch" + ), + signal_fn=lambda pid, sig: signaled.append((pid, sig)), + ) + assert result["executed"] == 1 + assert [pid for pid, _sig in signaled] == [target_identity.pid] + assert governed_board.kb.get_task(conn, target_id).status == "blocked" + assert governed_board.kb.get_task(conn, unrelated_id).status == "running" + unrelated_run = conn.execute( + "SELECT r.process_state,r.worker_pid FROM tasks t " + "JOIN task_runs r ON r.id=t.current_run_id WHERE t.id=?", + (unrelated_id,), + ).fetchone() + assert dict(unrelated_run) == { + "process_state": "registered", + "worker_pid": unrelated_identity.pid, + } + assert conn.execute( + "SELECT count(*) FROM kanban_effect_journal WHERE task_id=?", + (unrelated_id,), + ).fetchone()[0] == 0 + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_dispatcher_concurrently_executes_once_then_confirms_gone( + session_store, governed_board, monkeypatch +): + target_id, identity = _register_running_target( + governed_board, monkeypatch, title="dispatcher-effect" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus interrupt {target_id}", 213) + ) + calls = [] + + def process_once(operation: str): + conn = governed_board.kb.connect(board="default") + try: + return governed_board.kb.process_pending_worker_termination_effects( + conn, + olympus_auth=_service_auth( + conn, governed_board.context, operation + ), + signal_fn=lambda pid, sig: calls.append((pid, sig)), + ) + finally: + conn.close() + + await asyncio.gather( + *(asyncio.to_thread(process_once, f"effect:concurrent:{i}") for i in range(6)) + ) + assert len(calls) == 1 and calls[0][0] == identity.pid + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT state FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone()[0] == "applied" + finally: + conn.close() + monkeypatch.setattr( + governed_board.kb, "read_process_identity", lambda _pid: None + ) + monkeypatch.setattr(governed_board.kb, "_pid_alive", lambda _pid: False) + confirmations = await asyncio.gather( + *(asyncio.to_thread(process_once, f"effect:confirm:{i}") for i in range(6)) + ) + assert sum(result["confirmed"] for result in confirmations) == 1 + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT state FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone()[0] == "gone" + assert governed_board.kb.get_task(conn, target_id).status == "blocked" + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_failed_effect_is_not_replayed_or_finalized( + session_store, governed_board, monkeypatch +): + target_id, _ = _register_running_target( + governed_board, monkeypatch, title="failed-effect" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus cancel {target_id}", 214) + ) + calls = [] + + def fail_signal(pid, sig): + calls.append((pid, sig)) + raise RuntimeError("signal failure") + + conn = governed_board.kb.connect(board="default") + try: + result = governed_board.kb.process_pending_worker_termination_effects( + conn, + olympus_auth=_service_auth( + conn, governed_board.context, "effect:failed:first" + ), + signal_fn=fail_signal, + ) + assert result["executed"] == 1 + assert conn.execute( + "SELECT state FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone()[0] == "failed" + governed_board.kb.process_pending_worker_termination_effects( + conn, + olympus_auth=_service_auth( + conn, governed_board.context, "effect:failed:retry" + ), + signal_fn=lambda pid, sig: calls.append((pid, sig)), + ) + assert len(calls) == 1 + assert governed_board.kb.reconcile_olympus_telegram_controls( + conn, + service_auth=_service_auth( + conn, governed_board.context, "effect:failed:reconcile" + ), + ) == 0 + assert governed_board.kb.get_task(conn, target_id).status == "blocked" + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_cancel_reconciles_only_after_effect_terminal_and_reopen( + session_store, governed_board, monkeypatch +): + target_id, identity = _register_running_target( + governed_board, monkeypatch, title="cancel" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + result = await runner._handle_olympus_command( + _event(f"/olympus cancel {target_id}", 211) + ) + assert "status is `blocked`" in result + conn = governed_board.kb.connect(board="default") + try: + assert governed_board.kb.reconcile_olympus_telegram_controls( + conn, + service_auth=_service_auth(conn, governed_board.context, "cancel:reconcile"), + ) == 0 + effect_id = conn.execute( + "SELECT id FROM kanban_effect_journal WHERE effect_kind='terminate_worker'" + ).fetchone()[0] + calls = [] + assert governed_board.kb.execute_worker_termination_effect( + conn, + effect_id, + olympus_auth=_service_auth(conn, governed_board.context, "cancel:execute"), + signal_fn=lambda pid, sig: calls.append((pid, sig)), + ) == "applied" + assert calls and calls[0][0] == identity.pid + assert governed_board.kb.reconcile_olympus_telegram_controls( + conn, + service_auth=_service_auth( + conn, governed_board.context, "cancel:still-running" + ), + ) == 0 + finally: + conn.close() + monkeypatch.setattr( + governed_board.kb, "read_process_identity", lambda _pid: None + ) + monkeypatch.setattr(governed_board.kb, "_pid_alive", lambda _pid: False) + reopened = governed_board.kb.connect(board="default") + try: + assert governed_board.kb.confirm_applied_worker_termination_effects( + reopened, + olympus_auth=_service_auth( + reopened, governed_board.context, "cancel:confirm-gone" + ), + ) == 1 + assert governed_board.kb.reconcile_olympus_telegram_controls( + reopened, + service_auth=_service_auth( + reopened, governed_board.context, "cancel:reconcile" + ), + ) == 1 + assert governed_board.kb.get_task(reopened, target_id).status == "archived" + assert governed_board.kb.reconcile_olympus_telegram_controls( + reopened, + service_auth=_service_auth(reopened, governed_board.context, "cancel:reconcile"), + ) == 0 + finally: + reopened.close() + + +@pytest.mark.asyncio +async def test_running_cancel_stale_containment_generation_cannot_archive( + session_store, governed_board, monkeypatch +): + target_id, _ = _register_running_target( + governed_board, monkeypatch, title="cancel-stale-finalizer" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus cancel {target_id}", 2150) + ) + monkeypatch.setattr( + governed_board.kb, "read_process_identity", lambda _pid: None + ) + monkeypatch.setattr(governed_board.kb, "_pid_alive", lambda _pid: False) + conn = governed_board.kb.connect(board="default") + try: + effect = conn.execute( + "SELECT id,target_post_revision FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone() + assert governed_board.kb.execute_worker_termination_effect( + conn, + int(effect["id"]), + olympus_auth=_service_auth( + conn, governed_board.context, "cancel-stale:execute" + ), + ) == "gone" + assert governed_board.kb.set_task_status( + conn, + target_id, + "ready", + olympus_auth=_service_auth( + conn, governed_board.context, "cancel-stale:ready" + ), + ) + assert governed_board.kb.set_task_status( + conn, + target_id, + "blocked", + olympus_auth=_service_auth( + conn, governed_board.context, "cancel-stale:blocked" + ), + ) + task = governed_board.kb.get_task(conn, target_id) + assert task.record_revision > int(effect["target_post_revision"]) + assert governed_board.kb.reconcile_olympus_telegram_controls( + conn, + service_auth=_service_auth( + conn, governed_board.context, "cancel-stale:reconcile" + ), + ) == 0 + assert governed_board.kb.get_task(conn, target_id).status == "blocked" + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_concurrent_cancel_finalization_archives_exactly_once( + session_store, governed_board, monkeypatch +): + target_id, _ = _register_running_target( + governed_board, monkeypatch, title="cancel-concurrent-finalizer" + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus cancel {target_id}", 2151) + ) + monkeypatch.setattr( + governed_board.kb, "read_process_identity", lambda _pid: None + ) + monkeypatch.setattr(governed_board.kb, "_pid_alive", lambda _pid: False) + conn = governed_board.kb.connect(board="default") + try: + effect_id = conn.execute( + "SELECT id FROM kanban_effect_journal " + "WHERE effect_kind='terminate_worker'" + ).fetchone()[0] + assert governed_board.kb.execute_worker_termination_effect( + conn, + effect_id, + olympus_auth=_service_auth( + conn, governed_board.context, "cancel-concurrent:execute" + ), + ) == "gone" + finally: + conn.close() + + def finalize(index: int) -> int: + isolated = governed_board.kb.connect(board="default") + try: + return governed_board.kb.reconcile_olympus_telegram_controls( + isolated, + service_auth=_service_auth( + isolated, + governed_board.context, + f"cancel-concurrent:finalize:{index}", + ), + ) + finally: + isolated.close() + + results = await asyncio.gather( + *(asyncio.to_thread(finalize, index) for index in range(6)) + ) + assert sum(results) == 1 + reopened = governed_board.kb.connect(board="default") + try: + assert governed_board.kb.get_task(reopened, target_id).status == "archived" + finally: + reopened.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ("pause", "interrupt", "cancel")) +async def test_running_control_without_registered_process_fails_closed( + session_store, governed_board, action +): + conn = governed_board.kb.connect(board="default") + try: + target_id = _create_governed( + conn, governed_board.context, title=f"unregistered-{action}" + ) + assert governed_board.kb.reserve_worker_run( + conn, + target_id, + claimer=governed_board.kb._claimer_id(), + olympus_auth=_service_auth(conn, governed_board.context, "unregistered:claim"), + ) is not None + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + denied = await runner._handle_olympus_command( + _event(f"/olympus {action} {target_id}", 212) + ) + assert "lacks an exact registered process" in denied + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 0 + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_expired_and_foreign_targets_deny_without_receipt( + session_store, governed_board +): + foreign = _context(mission_id="M-foreign") + conn = governed_board.kb.connect(board="default") + try: + expired = copy.deepcopy(governed_board.context) + expired_id = _create_governed( + conn, expired, title="expired", initial_status="blocked" + ) + # Represent a previously valid row observed after its persisted lease + # and authority have expired. The migration guard is the only path + # allowed to install historical authority state without a live permit. + expired["authority"]["expires_at"] = int(time.time()) - 1 + expired["lease"]["expires_at"] = int(time.time()) - 1 + conn._olympus_schema_migration_depth = 1 + try: + conn.execute( + "UPDATE tasks SET olympus_context = ? WHERE id = ?", + ( + json.dumps( + expired, sort_keys=True, separators=(",", ":") + ), + expired_id, + ), + ) + finally: + conn._olympus_schema_migration_depth = 0 + foreign_id = _create_governed(conn, foreign, title="foreign") + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert "expired" in await runner._handle_olympus_command( + _event(f"/olympus resume {expired_id}", 220) + ) + assert "outside the selected" in await runner._handle_olympus_command( + _event(f"/olympus cancel {foreign_id}", 221) + ) + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 0 + finally: + conn.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ("pause", "resume", "interrupt", "cancel")) +@pytest.mark.parametrize("threat", ("expired", "revoked", "foreign")) +async def test_all_controls_deny_stale_revoked_or_foreign_authority_matrix( + session_store, governed_board, monkeypatch, action, threat +): + context = ( + _context(mission_id="M-foreign-matrix") + if threat == "foreign" + else copy.deepcopy(governed_board.context) + ) + if action == "interrupt": + target_id, _ = _register_running_target( + governed_board, + monkeypatch, + title=f"matrix-{threat}-{action}", + context=context, + ) + conn = governed_board.kb.connect(board="default") + else: + conn = governed_board.kb.connect(board="default") + target_id = _create_governed( + conn, + context, + title=f"matrix-{threat}-{action}", + initial_status="blocked" if action == "resume" else "running", + ) + try: + if threat != "foreign": + if threat == "expired": + context["authority"]["expires_at"] = int(time.time()) - 1 + context["lease"]["expires_at"] = int(time.time()) - 1 + else: + context["authority"]["status"] = "REVOKED" + context["lease"]["status"] = "REVOKED" + conn._olympus_schema_migration_depth = 1 + try: + conn.execute( + "UPDATE tasks SET olympus_context=? WHERE id=?", + ( + json.dumps( + context, sort_keys=True, separators=(",", ":") + ), + target_id, + ), + ) + finally: + conn._olympus_schema_migration_depth = 0 + finally: + conn.close() + runner = _runner(session_store) + _set_selection(session_store, governed_board) + denied = await runner._handle_olympus_command( + _event(f"/olympus {action} {target_id}", 225) + ) + assert "blocked" in denied + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 0 + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_direct_sql_insert_update_delete_matrix( + session_store, governed_board +): + runner = _runner(session_store) + _set_selection(session_store, governed_board) + assert (await runner._route_olympus_telegram_intake( + _event("journal guard", 240) + )).startswith("Queued") + conn = governed_board.kb.connect(board="default") + try: + target_id = _create_governed( + conn, governed_board.context, title="journal-control" + ) + db_path = conn.execute("PRAGMA database_list").fetchone()[2] + finally: + conn.close() + assert "status is `blocked`" in await runner._handle_olympus_command( + _event(f"/olympus pause {target_id}", 241) + ) + raw = sqlite3.connect(db_path) + try: + delivery_key = raw.execute( + "SELECT delivery_key FROM olympus_telegram_deliveries" + ).fetchone()[0] + operation_id = raw.execute( + "SELECT operation_id FROM olympus_telegram_controls" + ).fetchone()[0] + statements = ( + ( + "INSERT INTO olympus_telegram_deliveries VALUES " + "(?,?,?,?,?,?,?)", + ( + "olympus-telegram:v3:forged", governed_board.root_id, 1, + "t_forged", "{}", "0" * 64, 1, + ), + ), + ( + "UPDATE olympus_telegram_deliveries SET payload='{}' " + "WHERE delivery_key=?", + (delivery_key,), + ), + ( + "DELETE FROM olympus_telegram_deliveries WHERE delivery_key=?", + (delivery_key,), + ), + ( + "INSERT INTO olympus_telegram_controls VALUES " + "(?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "olympus-telegram-control:v3:forged", "telegram-control:pause", + governed_board.root_id, 1, target_id, 1, "{}", "{}", + "0" * 64, "forged", "blocked", None, 1, + ), + ), + ( + "UPDATE olympus_telegram_controls SET result_status='ready' " + "WHERE operation_id=?", + (operation_id,), + ), + ( + "DELETE FROM olympus_telegram_controls WHERE operation_id=?", + (operation_id,), + ), + ) + for statement, params in statements: + with pytest.raises(sqlite3.DatabaseError): + raw.execute(statement, params) + raw.rollback() + assert raw.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 1 + assert raw.execute( + "SELECT count(*) FROM olympus_telegram_controls" + ).fetchone()[0] == 1 + finally: + raw.close() + + +def test_valid_permit_cannot_be_reused_for_another_journal_tuple( + governed_board, +): + kb = governed_board.kb + conn = kb.connect(board="default") + try: + root = kb.get_task(conn, governed_board.root_id) + revision = root.record_revision + source = _source_identity() + source_json, _ = kb._canonical_json_record(source) + + delivery_auth = kb.olympus_telegram_auth( + conn, + verifier=_allow, + source_identity=source, + authorization_task_id=root.id, + target_task_id=root.id, + action="telegram-intake", + operation_id="cross-tuple:intake", + ) + delivery_payload, delivery_sha = kb._canonical_json_record( + {"schema_version": "cross-tuple/1", "tuple": "authorized"} + ) + delivery_binding = { + "schema_version": kb.TELEGRAM_DELIVERY_WRITE_SCHEMA, + "action": "telegram-intake", + "task_id": root.id, + "task_record_revision": revision, + "delivery_key": "olympus-telegram:v3:" + "1" * 64, + "authorization_task_id": root.id, + "authorization_task_revision": revision, + "created_task_id": "t_11111111", + "payload": delivery_payload, + "payload_sha256": delivery_sha, + "created_at": 1, + } + with kb.write_txn(conn), kb.olympus_mutation_scope(delivery_auth): + _, owns = kb._authorize_task_mutation( + conn, + root.id, + action="telegram-intake", + capability=kb.TELEGRAM_ACTION_CAPABILITIES["telegram-intake"], + auth=delivery_auth, + mutation_binding=delivery_binding, + ) + try: + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO olympus_telegram_deliveries VALUES " + "(?,?,?,?,?,?,?)", + ( + "olympus-telegram:v3:" + "2" * 64, + root.id, + revision, + "t_11111111", + delivery_payload, + delivery_sha, + 1, + ), + ) + finally: + kb._release_task_mutation_permit(conn, root.id, owns) + + control_auth = kb.olympus_telegram_auth( + conn, + verifier=_allow, + source_identity=source, + authorization_task_id=root.id, + target_task_id=root.id, + action="telegram-control:pause", + operation_id="olympus-telegram-control:v3:" + "3" * 64, + ) + request_payload, request_sha = kb._canonical_json_record( + {"schema_version": "cross-tuple-control/1"} + ) + control_binding = { + "schema_version": kb.TELEGRAM_CONTROL_WRITE_SCHEMA, + "action": "telegram-control:pause", + "task_id": root.id, + "task_record_revision": revision, + "operation_id": control_auth.operation_id, + "authorization_task_id": root.id, + "authorization_task_revision": revision, + "source_identity": source_json, + "request_payload": request_payload, + "payload_sha256": request_sha, + "result_status": "blocked", + "effect_operation_id": None, + "created_at": 2, + } + with kb.write_txn(conn), kb.olympus_mutation_scope(control_auth): + authorization, owns = kb._authorize_task_mutation( + conn, + root.id, + action="telegram-control:pause", + capability=kb.TELEGRAM_ACTION_CAPABILITIES[ + "telegram-control:pause" + ], + auth=control_auth, + mutation_binding=control_binding, + ) + try: + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO olympus_telegram_controls VALUES " + "(?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "olympus-telegram-control:v3:" + "4" * 64, + "telegram-control:pause", root.id, revision, + root.id, revision, source_json, request_payload, + request_sha, + authorization["verification"]["verification_id"], + "blocked", None, 2, + ), + ) + finally: + kb._release_task_mutation_permit(conn, root.id, owns) + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_busy_selected_message_never_interrupts_active_agent(session_store): + runner = _runner(session_store) + agent = MagicMock() + key = runner._session_key_for_source(_source()) + runner._running_agents[key] = agent + runner._route_olympus_telegram_intake = AsyncMock( + return_value="Queued `t_abc12345`" + ) + adapter = MagicMock() + adapter._send_with_retry = AsyncMock() + runner.adapters[Platform.TELEGRAM] = adapter + assert await runner._handle_active_session_busy_message(_event("new work", 300), key) + agent.interrupt.assert_not_called() + + +@pytest.mark.asyncio +async def test_busy_selected_intake_routes_only_to_exact_chat_thread_and_user( + session_store, governed_board +): + source = _source( + chat_id="chat-selected", + thread_id="thread-selected", + user_id="user-selected", + ) + event = _event( + "durable selected work", + 301, + chat_id="chat-selected", + thread_id="thread-selected", + user_id="user-selected", + ) + runner = _runner(session_store) + _set_selection(session_store, governed_board, source=source) + agent = MagicMock() + key = runner._session_key_for_source(source) + runner._running_agents[key] = agent + adapter = MagicMock() + adapter._bot = SimpleNamespace(id=int(BOT_ID)) + adapter._send_with_retry = AsyncMock() + runner.adapters[Platform.TELEGRAM] = adapter + + assert await runner._handle_active_session_busy_message(event, key) + agent.interrupt.assert_not_called() + adapter._send_with_retry.assert_awaited_once() + assert adapter._send_with_retry.await_args.kwargs["chat_id"] == "chat-selected" + + conn = governed_board.kb.connect(board="default") + try: + assert conn.execute( + "SELECT count(*) FROM olympus_telegram_deliveries" + ).fetchone()[0] == 1 + assert conn.execute( + "SELECT count(*) FROM tasks WHERE id != ?", + (governed_board.root_id,), + ).fetchone()[0] == 1 + subscription = conn.execute( + "SELECT platform,chat_id,thread_id,user_id,notifier_profile " + "FROM kanban_notify_subs" + ).fetchone() + assert dict(subscription) == { + "platform": "telegram", + "chat_id": "chat-selected", + "thread_id": "thread-selected", + "user_id": "user-selected", + "notifier_profile": PROFILE, + } + assert conn.execute( + "SELECT count(*) FROM kanban_notify_subs " + "WHERE chat_id IN ('chat-unrelated','1001') " + "OR thread_id='thread-unrelated' OR user_id='user-unrelated'" + ).fetchone()[0] == 0 + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_telegram_background_requires_selection_or_explicit_ephemeral(session_store): + runner = _runner(session_store) + assert "requires an Olympus selection" in await runner._handle_background_command( + _event("/background research this", 310) + ) + runner._run_background_task = AsyncMock() + assert "Background task started" in await runner._handle_background_command( + _event("/background --ephemeral research this", 311) + ) + + +def test_olympus_command_bypasses_active_session_guard(): + from hermes_cli.commands import should_bypass_active_session + + assert should_bypass_active_session("olympus") is True diff --git a/tests/hermes_cli/test_kanban_olympus_authority.py b/tests/hermes_cli/test_kanban_olympus_authority.py index 64be9a8eab2b..4af491d160d0 100644 --- a/tests/hermes_cli/test_kanban_olympus_authority.py +++ b/tests/hermes_cli/test_kanban_olympus_authority.py @@ -825,7 +825,7 @@ def test_process_fenced_manual_reclaim_stages_executes_and_settles(conn, monkeyp ).fetchone() assert settled["state"] == "applied" assert settled["worker_start_token"] == identity.start_token - assert kb.get_run(conn, run.id).process_state == "terminal" + assert kb.get_run(conn, run.id).process_state == "termination_sent" def test_process_fence_never_signals_reused_pid(conn, monkeypatch): @@ -952,7 +952,7 @@ def failpoint(stage: str) -> None: ).fetchone()[0] assert process_state == ( "termination_pending" if crash_stage == "after_stage" - else "terminal" if crash_stage == "after_settle" + else "termination_sent" if crash_stage == "after_settle" else "identity_unverified" ) assert len(signals) == expected_signals From ed5f9dd7ef87a95f2cc1913a36de3c9a1c186b12 Mon Sep 17 00:00:00 2001 From: Chad Date: Tue, 14 Jul 2026 11:02:11 -0400 Subject: [PATCH 2/3] fix(telegram): dispatch Olympus commands end to end --- gateway/run.py | 10 +++++ .../test_command_bypass_active_session.py | 12 +++++ tests/gateway/test_olympus_telegram_router.py | 44 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index b167fb5ee1de..ef4967bba054 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9120,6 +9120,13 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) + # Olympus is a Telegram control-plane command. Dispatch it while a + # conversational agent is active rather than returning the generic + # busy response; its own handler enforces platform, authority, and + # durable idempotency boundaries. + if _cmd_def_inner and _cmd_def_inner.name == "olympus": + return await self._handle_olympus_command(event) + # /kanban must bypass the guard. It writes to a profile-agnostic # DB (kanban.db), not to the running agent's state. In fact # /kanban unblock is often the only way to free a worker that @@ -9522,6 +9529,9 @@ async def _do_reset(): if canonical == "kanban": return await self._handle_kanban_command(event) + if canonical == "olympus": + return await self._handle_olympus_command(event) + if canonical == "suggestions": return await self._handle_suggestions_command(event) diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index b741f667edd8..56696d15456a 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -200,6 +200,18 @@ async def test_background_bypasses_guard(self): "/background response was not sent back to the user" ) + @pytest.mark.asyncio + async def test_olympus_bypasses_guard(self): + """/olympus must reach the runner instead of entering the chat queue.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/olympus status")) + + assert sk not in adapter._pending_messages + assert any("handled:olympus" in r for r in adapter.sent_responses) + @pytest.mark.asyncio async def test_steer_bypasses_guard(self): """/steer must bypass the Level-1 active-session guard so it reaches diff --git a/tests/gateway/test_olympus_telegram_router.py b/tests/gateway/test_olympus_telegram_router.py index cf89dca7ff7b..0f6b763307eb 100644 --- a/tests/gateway/test_olympus_telegram_router.py +++ b/tests/gateway/test_olympus_telegram_router.py @@ -2152,3 +2152,47 @@ def test_olympus_command_bypasses_active_session_guard(): from hermes_cli.commands import should_bypass_active_session assert should_bypass_active_session("olympus") is True + + +def _install_message_dispatch_stubs(runner): + runner._scale_to_zero_note_real_inbound = lambda: None + runner._route_olympus_telegram_intake = AsyncMock(return_value=None) + runner._check_slash_access = lambda *_args, **_kwargs: None + runner.hooks = SimpleNamespace( + emit=AsyncMock(), + emit_collect=AsyncMock(return_value=[]), + loaded_hooks=False, + ) + + +@pytest.mark.asyncio +async def test_cold_runner_dispatches_olympus_command(session_store): + runner = _runner(session_store) + _install_message_dispatch_stubs(runner) + runner._handle_olympus_command = AsyncMock(return_value="olympus:cold") + event = _event("/olympus status", 320) + + assert await runner._handle_message(event) == "olympus:cold" + runner._handle_olympus_command.assert_awaited_once_with(event) + + +@pytest.mark.asyncio +async def test_active_runner_dispatches_olympus_command(session_store): + runner = _runner(session_store) + _install_message_dispatch_stubs(runner) + runner._handle_olympus_command = AsyncMock(return_value="olympus:active") + event = _event("/olympus status", 321) + key = runner._session_key_for_source(event.source) + agent = MagicMock() + agent.get_activity_summary.return_value = { + "seconds_since_activity": 0, + "last_activity_desc": "synthetic active command", + "api_call_count": 1, + "max_iterations": 10, + } + runner._running_agents[key] = agent + runner._running_agents_ts[key] = time.time() + + assert await runner._handle_message(event) == "olympus:active" + runner._handle_olympus_command.assert_awaited_once_with(event) + agent.interrupt.assert_not_called() From 7f1ce9d1d8bf25cef9b5b1759e4abafc3034b367 Mon Sep 17 00:00:00 2001 From: Chad Date: Tue, 14 Jul 2026 11:25:17 -0400 Subject: [PATCH 3/3] test(telegram): align synthetic authority clock --- tests/gateway/test_olympus_telegram_router.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_olympus_telegram_router.py b/tests/gateway/test_olympus_telegram_router.py index 0f6b763307eb..ae9c63ce9ab2 100644 --- a/tests/gateway/test_olympus_telegram_router.py +++ b/tests/gateway/test_olympus_telegram_router.py @@ -68,7 +68,10 @@ def _allow_at(request: dict, now: float) -> dict: def _allow(request: dict) -> dict: - return _allow_at(request, time.time()) + # Production freezes the request validation clock to integer seconds. + # Match that boundary so a verifier call crossing into the next second + # cannot report verified_at a fraction later than the frozen request time. + return _allow_at(request, float(int(time.time()))) def _source(