From f8fcd561f8dbdb46038191419d1c743971e18e8d Mon Sep 17 00:00:00 2001 From: Magnus Lundstedt Date: Thu, 27 Aug 2026 02:20:48 +0200 Subject: [PATCH 1/2] fix(mcp): _stdio_children_dead returned True on the first *alive* child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _stdio_children_dead() is documented as 'True when every stdio child we spawned has exited', but the loop returns True as soon as it finds a child that is still ALIVE: if not psutil.pid_exists(pid): continue # dead → check next return True # ← a LIVE child wrongly reports 'all dead' return False # ← unreachable So the #81995 fast-fail path (_stdio_children_dead() → raise TimeoutError) aborts every stdio MCP tool call with 'MCP stdio subprocess for X has exited' while the servers are perfectly alive. Return False as soon as any tracked child is alive; return True only when none are — the correct meaning of the docstring. --- tools/mcp_tool.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index c1c80e5adb18a..98b1cd25e784d 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2999,11 +2999,9 @@ def _stdio_children_dead(self) -> bool: # os.kill probe below only runs when psutil is unavailable. import psutil - if not psutil.pid_exists(pid): - continue # this one is dead - return True # alive (signal permission irrelevant for liveness) - return False # at least one child alive - return True + if psutil.pid_exists(pid): + return False # at least one child alive → not all have exited + return True # no tracked child is alive → every child has exited async def _watch_stdio_children(self) -> None: """Poll child liveness while a stdio RPC is in flight (#81995). From 9fd12ab27cebe4c3742f57cba9a5d212b8e28fec Mon Sep 17 00:00:00 2001 From: Magnus Lundstedt Date: Thu, 27 Aug 2026 02:20:48 +0200 Subject: [PATCH 2/2] fix(cron): trust the gateway runtime lock for builtin-ticker liveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _builtin_gateway_liveness() decides whether the builtin cron ticker can fire by PID-scanning via find_gateway_pids(). That scan can transiently return empty even while the gateway is up (e.g. just after a restart), so the in-gateway cronjob tool emits a false 'Gateway is not running — jobs won't fire' while jobs are firing on time. Prefer the gateway runtime lock: it is held for exactly the gateway's lifetime (a reliable liveness signal), and inside the gateway process it short-circuits to True, so the in-gateway check can never false-alarm. Fall back to the PID scan only when the lock reads inactive (the external-CLI path). --- hermes_cli/cron.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 486eaba9518cf..a15c7bc6b558d 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -78,6 +78,15 @@ def _builtin_gateway_liveness() -> Optional[bool]: try: if _active_cron_provider_name() != "builtin": return True # external provider fires jobs without the gateway + # The gateway runtime lock is held for exactly the gateway's lifetime, so it + # is a more reliable "is the ticker's process alive" signal than PID scanning + # — and inside the gateway process it short-circuits to True, so the in-gateway + # cron tool never emits a false "gateway not running" (find_gateway_pids can + # transiently miss the gateway just after a restart). + from gateway.status import is_gateway_runtime_lock_active + + if is_gateway_runtime_lock_active(): + return True from hermes_cli.gateway import find_gateway_pids return bool(find_gateway_pids())