Skip to content
30 changes: 25 additions & 5 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,14 @@ class MessageType(Enum):
COMMAND = "command" # /command style


class ProcessingOutcome(Enum):
"""Result classification for message-processing lifecycle hooks."""

SUCCESS = "success"
FAILURE = "failure"
CANCELLED = "cancelled"


@dataclass
class MessageEvent:
"""
Expand Down Expand Up @@ -625,6 +633,7 @@ def __init__(self, config: PlatformConfig, platform: Platform):
# Gateway shutdown cancels these so an old gateway instance doesn't keep
# working on a task after --replace or manual restarts.
self._background_tasks: set[asyncio.Task] = set()
self._expected_cancelled_tasks: set[asyncio.Task] = set()
# Chats where auto-TTS on voice input is disabled (set by /voice off)
self._auto_tts_disabled_chats: set = set()
# Chats where typing indicator is paused (e.g. during approval waits).
Expand Down Expand Up @@ -1133,7 +1142,7 @@ def resume_typing_for_chat(self, chat_id: str) -> None:
async def on_processing_start(self, event: MessageEvent) -> None:
"""Hook called when background processing begins."""

async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
"""Hook called when background processing completes."""

async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
Expand Down Expand Up @@ -1294,7 +1303,7 @@ async def handle_message(self, event: MessageEvent) -> None:
# session lifecycle and its cleanup races with the running task
# (see PR #4926).
cmd = event.get_command()
if cmd in ("approve", "deny", "status", "stop", "new", "reset"):
if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background"):
logger.debug(
"[%s] Command '/%s' bypassing active-session guard for %s",
self.name, cmd, session_key,
Expand Down Expand Up @@ -1352,6 +1361,7 @@ async def handle_message(self, event: MessageEvent) -> None:
return
if hasattr(task, "add_done_callback"):
task.add_done_callback(self._background_tasks.discard)
task.add_done_callback(self._expected_cancelled_tasks.discard)

@staticmethod
def _get_human_delay() -> float:
Expand Down Expand Up @@ -1580,7 +1590,11 @@ def _record_delivery(result):

# Determine overall success for the processing hook
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
await self._run_processing_hook("on_processing_complete", event, processing_ok)
await self._run_processing_hook(
"on_processing_complete",
event,
ProcessingOutcome.SUCCESS if processing_ok else ProcessingOutcome.FAILURE,
)

# Check if there's a pending message that was queued during our processing
if session_key in self._pending_messages:
Expand All @@ -1599,10 +1613,14 @@ def _record_delivery(result):
return # Already cleaned up

except asyncio.CancelledError:
await self._run_processing_hook("on_processing_complete", event, False)
current_task = asyncio.current_task()
outcome = ProcessingOutcome.CANCELLED
if current_task is None or current_task not in self._expected_cancelled_tasks:
outcome = ProcessingOutcome.FAILURE
await self._run_processing_hook("on_processing_complete", event, outcome)
raise
except Exception as e:
await self._run_processing_hook("on_processing_complete", event, False)
await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.FAILURE)
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
# Send the error to the user so they aren't left with radio silence
try:
Expand Down Expand Up @@ -1646,10 +1664,12 @@ async def cancel_background_tasks(self) -> None:
"""
tasks = [task for task in self._background_tasks if not task.done()]
for task in tasks:
self._expected_cancelled_tasks.add(task)
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._background_tasks.clear()
self._expected_cancelled_tasks.clear()
self._pending_messages.clear()
self._active_sessions.clear()

Expand Down
8 changes: 6 additions & 2 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
BasePlatformAdapter,
MessageEvent,
MessageType,
ProcessingOutcome,
SendResult,
cache_image_from_url,
cache_audio_from_url,
Expand Down Expand Up @@ -754,14 +755,17 @@ async def on_processing_start(self, event: MessageEvent) -> None:
if hasattr(message, "add_reaction"):
await self._add_reaction(message, "👀")

async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
"""Swap the in-progress reaction for a final success/failure reaction."""
if not self._reactions_enabled():
return
message = event.raw_message
if hasattr(message, "add_reaction"):
await self._remove_reaction(message, "👀")
await self._add_reaction(message, "✅" if success else "❌")
if outcome == ProcessingOutcome.SUCCESS:
await self._add_reaction(message, "✅")
elif outcome == ProcessingOutcome.FAILURE:
await self._add_reaction(message, "❌")

async def send(
self,
Expand Down
3 changes: 2 additions & 1 deletion gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,8 @@ async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any:
return await original_connect(*args, **kwargs)

def _configure_with_overrides(conf: Any) -> Any:
assert original_configure is not None
if original_configure is None:
raise RuntimeError("Feishu _configure_with_overrides called but original_configure is None")
result = original_configure(conf)
_apply_runtime_ws_overrides()
return result
Expand Down
9 changes: 7 additions & 2 deletions gateway/platforms/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
BasePlatformAdapter,
MessageEvent,
MessageType,
ProcessingOutcome,
SendResult,
)

Expand Down Expand Up @@ -1479,7 +1480,7 @@ async def on_processing_start(self, event: MessageEvent) -> None:
await self._send_reaction(room_id, msg_id, "\U0001f440")

async def on_processing_complete(
self, event: MessageEvent, success: bool,
self, event: MessageEvent, outcome: ProcessingOutcome,
) -> None:
"""Replace eyes with checkmark (success) or cross (failure)."""
if not self._reactions_enabled:
Expand All @@ -1488,11 +1489,15 @@ async def on_processing_complete(
room_id = event.source.chat_id
if not msg_id or not room_id:
return
if outcome == ProcessingOutcome.CANCELLED:
return
# Note: Matrix doesn't support removing a specific reaction easily
# without tracking the reaction event_id. We send the new reaction;
# the eyes stays (acceptable UX — both are visible).
await self._send_reaction(
room_id, msg_id, "\u2705" if success else "\u274c",
room_id,
msg_id,
"\u2705" if outcome == ProcessingOutcome.SUCCESS else "\u274c",
)

async def _on_reaction(self, room: Any, event: Any) -> None:
Expand Down
67 changes: 59 additions & 8 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class _MockContextTypes:
BasePlatformAdapter,
MessageEvent,
MessageType,
ProcessingOutcome,
SendResult,
cache_image_from_bytes,
cache_audio_from_bytes,
Expand Down Expand Up @@ -517,6 +518,36 @@ async def connect(self) -> bool:

# Build the application
builder = Application.builder().token(self.config.token)

# PTB defaults (pool_timeout=1s) are too aggressive on flaky networks and
# can trigger "Pool timeout: All connections in the connection pool are occupied"
# during reconnect/bootstrap. Use safer defaults and allow env overrides.
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default

def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default

request_kwargs = {
"connection_pool_size": _env_int("HERMES_TELEGRAM_HTTP_POOL_SIZE", 512),
"pool_timeout": _env_float("HERMES_TELEGRAM_HTTP_POOL_TIMEOUT", 8.0),
"connect_timeout": _env_float("HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT", 10.0),
"read_timeout": _env_float("HERMES_TELEGRAM_HTTP_READ_TIMEOUT", 20.0),
"write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0),
}

proxy_configured = any(
(os.getenv(k) or "").strip()
for k in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
)
disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in ("1", "true", "yes", "on"))

fallback_ips = self._fallback_ips()
if not fallback_ips:
fallback_ips = await discover_fallback_ips()
Expand All @@ -525,16 +556,32 @@ async def connect(self) -> bool:
self.name,
", ".join(fallback_ips),
)
if fallback_ips:

if fallback_ips and not proxy_configured and not disable_fallback:
logger.info(
"[%s] Telegram fallback IPs active: %s",
self.name,
", ".join(fallback_ips),
)
transport = TelegramFallbackTransport(fallback_ips)
request = HTTPXRequest(httpx_kwargs={"transport": transport})
get_updates_request = HTTPXRequest(httpx_kwargs={"transport": transport})
builder = builder.request(request).get_updates_request(get_updates_request)
# Keep request/update pools separate to reduce contention during
# polling reconnect + bot API bootstrap/delete_webhook calls.
request = HTTPXRequest(
**request_kwargs,
httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
)
get_updates_request = HTTPXRequest(
**request_kwargs,
httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
)
else:
if proxy_configured:
logger.info("[%s] Proxy configured; skipping Telegram fallback-IP transport", self.name)
elif disable_fallback:
logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name)
request = HTTPXRequest(**request_kwargs)
get_updates_request = HTTPXRequest(**request_kwargs)

builder = builder.request(request).get_updates_request(get_updates_request)
self._app = builder.build()
self._bot = self._app.bot

Expand Down Expand Up @@ -2732,7 +2779,7 @@ async def on_processing_start(self, event: MessageEvent) -> None:
if chat_id and message_id:
await self._set_reaction(chat_id, message_id, "\U0001f440")

async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
"""Swap the in-progress reaction for a final success/failure reaction.

Unlike Discord (additive reactions), Telegram's set_message_reaction
Expand All @@ -2742,5 +2789,9 @@ async def on_processing_complete(self, event: MessageEvent, success: bool) -> No
return
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if chat_id and message_id:
await self._set_reaction(chat_id, message_id, "\u2705" if success else "\u274c")
if chat_id and message_id and outcome != ProcessingOutcome.CANCELLED:
await self._set_reaction(
chat_id,
message_id,
"\u2705" if outcome == ProcessingOutcome.SUCCESS else "\u274c",
)
3 changes: 2 additions & 1 deletion gateway/platforms/telegram_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
logger.warning("[Telegram] Fallback IP %s failed: %s", ip, exc)
continue

assert last_error is not None
if last_error is None:
raise RuntimeError("All Telegram fallback IPs exhausted but no error was recorded")
raise last_error

async def aclose(self) -> None:
Expand Down
15 changes: 10 additions & 5 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1991,6 +1991,11 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
return await self._handle_approve_command(event)
return await self._handle_deny_command(event)

# /background must bypass the running-agent guard — it starts a
# parallel task and must never interrupt the active conversation.
if _cmd_def_inner and _cmd_def_inner.name == "background":
return await self._handle_background_command(event)

if event.message_type == MessageType.PHOTO:
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20])
adapter = self.adapters.get(source.platform)
Expand Down Expand Up @@ -7577,7 +7582,7 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
# setups (each profile using a distinct HERMES_HOME) will naturally
# allow concurrent instances without tripping this guard.
import time as _time
from gateway.status import get_running_pid, remove_pid_file
from gateway.status import get_running_pid, remove_pid_file, terminate_pid
existing_pid = get_running_pid()
if existing_pid is not None and existing_pid != os.getpid():
if replace:
Expand All @@ -7586,10 +7591,10 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
existing_pid,
)
try:
os.kill(existing_pid, signal.SIGTERM)
terminate_pid(existing_pid, force=False)
except ProcessLookupError:
pass # Already gone
except PermissionError:
except (PermissionError, OSError):
logger.error(
"Permission denied killing PID %d. Cannot replace.",
existing_pid,
Expand All @@ -7609,9 +7614,9 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
existing_pid,
)
try:
os.kill(existing_pid, signal.SIGKILL)
terminate_pid(existing_pid, force=True)
_time.sleep(0.5)
except (ProcessLookupError, PermissionError):
except (ProcessLookupError, PermissionError, OSError):
pass
remove_pid_file()
# Also release all scoped locks left by the old process.
Expand Down
30 changes: 30 additions & 0 deletions gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import hashlib
import json
import os
import signal
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
Expand All @@ -23,6 +25,7 @@
_GATEWAY_KIND = "hermes-gateway"
_RUNTIME_STATUS_FILE = "gateway_state.json"
_LOCKS_DIRNAME = "gateway-locks"
_IS_WINDOWS = sys.platform == "win32"


def _get_pid_path() -> Path:
Expand All @@ -49,6 +52,33 @@ def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()


def terminate_pid(pid: int, *, force: bool = False) -> None:
"""Terminate a PID with platform-appropriate force semantics.

POSIX uses SIGTERM/SIGKILL. Windows uses taskkill /T /F for true force-kill
because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop.
"""
if force and _IS_WINDOWS:
try:
result = subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True,
text=True,
timeout=10,
)
except FileNotFoundError:
os.kill(pid, signal.SIGTERM)
return

if result.returncode != 0:
details = (result.stderr or result.stdout or "").strip()
raise OSError(details or f"taskkill failed for PID {pid}")
return

sig = signal.SIGTERM if not force else getattr(signal, "SIGKILL", signal.SIGTERM)
os.kill(pid, sig)


def _scope_hash(identity: str) -> str:
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]

Expand Down
Loading
Loading