Compare commits
1 Commits
fix/circui
...
feat/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e68c4d3e4e |
@@ -10,6 +10,7 @@ runs at a time if multiple processes overlap.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -50,6 +51,71 @@ _KNOWN_DELIVERY_PLATFORMS = frozenset({
|
|||||||
|
|
||||||
from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run
|
from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deploy Sync Guard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The scheduler passes keyword arguments to AIAgent() that may not exist in
|
||||||
|
# older installed versions. When the installed run_agent.py diverges from the
|
||||||
|
# version the scheduler was written against, every cron job fails with a
|
||||||
|
# TypeError — a silent total outage. This guard catches that at the first
|
||||||
|
# tick rather than after 1000+ error log lines.
|
||||||
|
#
|
||||||
|
# The check runs once per gateway process (cached by _agent_interface_validated).
|
||||||
|
# It inspects AIAgent.__init__ and verifies every parameter the scheduler
|
||||||
|
# passes is accepted. On mismatch it raises RuntimeError with a fix command.
|
||||||
|
|
||||||
|
_agent_interface_validated = False
|
||||||
|
|
||||||
|
# Parameters the scheduler passes to AIAgent() in run_job().
|
||||||
|
# If you add a new kwarg to the AIAgent() call below, add it here too.
|
||||||
|
_SCHEDULER_AGENT_KWARGS = {
|
||||||
|
"tool_choice": "required",
|
||||||
|
"skip_memory": True,
|
||||||
|
"platform": "cron",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_agent_interface():
|
||||||
|
"""Verify AIAgent.__init__ accepts every kwarg the scheduler uses.
|
||||||
|
|
||||||
|
Raises RuntimeError with a remediation message on mismatch.
|
||||||
|
Called once per process from run_job(); subsequent calls are no-ops.
|
||||||
|
"""
|
||||||
|
global _agent_interface_validated
|
||||||
|
if _agent_interface_validated:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from run_agent import AIAgent
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Deploy sync guard: cannot import AIAgent from run_agent: {exc}\n"
|
||||||
|
"The installed hermes-agent package may be corrupted. "
|
||||||
|
"Reinstall: pip install -e ~/.hermes/hermes-agent"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
sig = inspect.signature(AIAgent.__init__)
|
||||||
|
params = set(sig.parameters.keys()) - {"self"}
|
||||||
|
|
||||||
|
missing = [kw for kw in _SCHEDULER_AGENT_KWARGS if kw not in params]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Deploy sync guard: AIAgent.__init__() is missing parameters that "
|
||||||
|
"the cron scheduler requires. This means the installed code is out "
|
||||||
|
"of sync with the scheduler module.\n"
|
||||||
|
f" Missing parameters: {', '.join(missing)}\n"
|
||||||
|
f" Expected by: cron/scheduler.py (run_job → AIAgent())\n"
|
||||||
|
f" Fix: pip install -e ~/.hermes/hermes-agent --force-reinstall\n"
|
||||||
|
f" Then restart the gateway."
|
||||||
|
)
|
||||||
|
|
||||||
|
_agent_interface_validated = True
|
||||||
|
logger.info(
|
||||||
|
"Deploy sync guard: AIAgent interface OK (%d params, %d scheduler deps)",
|
||||||
|
len(params), len(_SCHEDULER_AGENT_KWARGS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Sentinel: when a cron agent has nothing new to report, it can start its
|
# Sentinel: when a cron agent has nothing new to report, it can start its
|
||||||
# response with this marker to suppress delivery. Output is still saved
|
# response with this marker to suppress delivery. Output is still saved
|
||||||
# locally for audit.
|
# locally for audit.
|
||||||
@@ -470,7 +536,12 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||||||
Tuple of (success, full_output_doc, final_response, error_message)
|
Tuple of (success, full_output_doc, final_response, error_message)
|
||||||
"""
|
"""
|
||||||
from run_agent import AIAgent
|
from run_agent import AIAgent
|
||||||
|
|
||||||
|
# Deploy sync guard: verify the installed AIAgent accepts all kwargs
|
||||||
|
# the scheduler passes. Catches stale-code-deploy bugs before they
|
||||||
|
# cascade into 1000+ TypeErrors and gateway restart loops.
|
||||||
|
_validate_agent_interface()
|
||||||
|
|
||||||
# Initialize SQLite session store so cron job messages are persisted
|
# Initialize SQLite session store so cron job messages are persisted
|
||||||
# and discoverable via session_search (same pattern as gateway/run.py).
|
# and discoverable via session_search (same pattern as gateway/run.py).
|
||||||
_session_db = None
|
_session_db = None
|
||||||
|
|||||||
@@ -863,3 +863,54 @@ class TestTickAdvanceBeforeRun:
|
|||||||
adv_mock.assert_called_once_with("test-advance")
|
adv_mock.assert_called_once_with("test-advance")
|
||||||
# advance must happen before run
|
# advance must happen before run
|
||||||
assert call_order == [("advance", "test-advance"), ("run", "test-advance")]
|
assert call_order == [("advance", "test-advance"), ("run", "test-advance")]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeploySyncGuard:
|
||||||
|
"""Tests for _validate_agent_interface() — the deploy sync guard."""
|
||||||
|
|
||||||
|
def test_passes_when_all_params_present(self):
|
||||||
|
"""Validation passes when AIAgent accepts every scheduler kwarg."""
|
||||||
|
from cron.scheduler import _validate_agent_interface, _agent_interface_validated
|
||||||
|
import cron.scheduler as sched_mod
|
||||||
|
|
||||||
|
# Reset the cached flag so the check actually runs.
|
||||||
|
sched_mod._agent_interface_validated = False
|
||||||
|
# Should not raise.
|
||||||
|
_validate_agent_interface()
|
||||||
|
assert sched_mod._agent_interface_validated is True
|
||||||
|
|
||||||
|
def test_fails_when_param_missing(self):
|
||||||
|
"""Validation raises RuntimeError when AIAgent is missing a required param."""
|
||||||
|
import cron.scheduler as sched_mod
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
# Save and restore.
|
||||||
|
orig_flag = sched_mod._agent_interface_validated
|
||||||
|
try:
|
||||||
|
sched_mod._agent_interface_validated = False
|
||||||
|
|
||||||
|
# Build a fake AIAgent class whose __init__ lacks 'tool_choice'.
|
||||||
|
class FakeAIAgent:
|
||||||
|
def __init__(self, model="", max_iterations=90, quiet_mode=False,
|
||||||
|
disabled_toolsets=None, skip_memory=False, platform=None,
|
||||||
|
session_id=None, session_db=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake_module = MagicMock()
|
||||||
|
fake_module.AIAgent = FakeAIAgent
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Missing parameters: tool_choice"):
|
||||||
|
with patch.dict("sys.modules", {"run_agent": fake_module}):
|
||||||
|
sched_mod._validate_agent_interface()
|
||||||
|
finally:
|
||||||
|
sched_mod._agent_interface_validated = orig_flag
|
||||||
|
|
||||||
|
def test_cached_after_first_run(self):
|
||||||
|
"""Second call is a no-op (uses cached flag)."""
|
||||||
|
import cron.scheduler as sched_mod
|
||||||
|
|
||||||
|
sched_mod._agent_interface_validated = True
|
||||||
|
# Should not raise even if we somehow break AIAgent — the flag is set.
|
||||||
|
sched_mod._validate_agent_interface()
|
||||||
|
# No exception = pass.
|
||||||
|
|||||||
Reference in New Issue
Block a user