Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4158,8 +4158,20 @@ def tick(

due_jobs = get_due_jobs()

if verbose and not due_jobs:
logger.info("%s - No jobs due", _hermes_now().strftime('%H:%M:%S'))
if not due_jobs:
# Idle tick: skip config load + pool partitioning entirely
# (#33612 — the gateway ticker calls tick(verbose=False) every
# 60s, so idle ticks previously fell through to load_config()).
# Still run the post-tick MCP orphan sweep: main intentionally
# sweeps on idle ticks so orphaned stdio children from crashed
# jobs are reaped even when nothing is due.
if verbose:
logger.info("%s - No jobs due", _hermes_now().strftime('%H:%M:%S'))
try:
from tools.mcp_tool import _kill_orphaned_mcp_children
_kill_orphaned_mcp_children()
except Exception as _e:
logger.debug("Post-tick MCP orphan cleanup failed: %s", _e)
return 0

if verbose:
Expand Down
58 changes: 58 additions & 0 deletions tests/cron/test_idle_tick_config_skip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Idle cron ticks must not load config (#33612 salvage).

The gateway's built-in ticker calls tick(verbose=False) every 60s. Before
the fix, idle ticks (no due jobs) fell through the verbose-only early
return and paid a full load_config() + worker-pool resolution per tick.
The fix returns early on ANY idle tick while preserving the post-tick MCP
orphan sweep that main intentionally runs even when nothing is due.
"""

from __future__ import annotations

from unittest.mock import patch

import cron.scheduler as scheduler_mod


def _run_idle_tick(**kwargs):
"""Run tick() with no due jobs; return (load_config_called, sweep_called)."""
calls = {"load_config": 0, "sweep": 0}

def _fake_load_config(*a, **k):
calls["load_config"] += 1
return {}

def _fake_sweep():
calls["sweep"] += 1

with (
patch.object(scheduler_mod, "get_due_jobs", return_value=[]),
patch.object(scheduler_mod, "load_config", side_effect=_fake_load_config),
patch(
"tools.mcp_tool._kill_orphaned_mcp_children",
side_effect=_fake_sweep,
),
):
rc = scheduler_mod.tick(verbose=kwargs.get("verbose", False))
return rc, calls


class TestIdleTickSkipsConfigLoad:
def test_idle_nonverbose_tick_skips_load_config(self):
"""Gateway-style tick(verbose=False) with no due jobs: no config load."""
rc, calls = _run_idle_tick(verbose=False)
assert rc == 0
assert calls["load_config"] == 0, (
"idle tick must not load config (was loading every 60s in the gateway ticker)"
)

def test_idle_verbose_tick_skips_load_config(self):
rc, calls = _run_idle_tick(verbose=True)
assert rc == 0
assert calls["load_config"] == 0

def test_idle_tick_still_sweeps_mcp_orphans(self):
"""The idle-tick orphan sweep is intentional on main — must survive."""
rc, calls = _run_idle_tick(verbose=False)
assert rc == 0
assert calls["sweep"] == 1, "idle tick must still reap orphaned MCP children"
Loading