Files
2026-07-13 13:12:33 +08:00

591 lines
19 KiB
Python

"""FailureDestination data layer + RPC wire + delivery routing.
Each cron job can send failure notifications to a different channel/webhook
than the primary delivery via ``DeliveryConfig.failure_destination`` (a
``FailureDestination`` dataclass with mode + channel/to/account or webhook
URL). On a failed run, ``DeliveryChain`` routes to the FD instead of the
primary target — operators can separate alert routing from successful
output. This test file covers the data plumbing through types,
persistence, RPC, and the actual failure-routing decision.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import pytest
from opensquilla.gateway.rpc import RpcContext
from opensquilla.gateway.rpc_cron import _handle_cron_add, _job_to_wire
from opensquilla.scheduler.delivery import DeliveryChain
from opensquilla.scheduler.jobs import (
execute_with_timeout,
set_failure_dispatcher,
)
from opensquilla.scheduler.ops import SchedulerOps
from opensquilla.scheduler.payloads import (
AGENT_TURN_KIND,
make_agent_turn_payload,
)
from opensquilla.scheduler.persistence import JobStore
from opensquilla.scheduler.types import (
CronJob,
DeliveryConfig,
DeliveryMode,
FailureDestination,
ScheduleKind,
SessionTarget,
)
# --- persistence round-trip ------------------------------------------------
async def test_failure_destination_channel_round_trips(tmp_path: Path) -> None:
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
ops = SchedulerOps(store)
fd = FailureDestination(
mode=DeliveryMode.CHANNEL,
channel_name="slack",
channel_id="C-ops",
account_id="T1",
)
delivery = DeliveryConfig(mode=DeliveryMode.NONE, failure_destination=fd)
job = await ops.add(
name="job",
schedule_kind=ScheduleKind.CRON,
schedule_value="*/5 * * * *",
handler_key="agent_run",
payload=make_agent_turn_payload("x"),
session_target=SessionTarget.ISOLATED,
delivery=delivery,
)
assert job.delivery.failure_destination is not None
assert job.delivery.failure_destination.channel_id == "C-ops"
reloaded = await store.get(job.id)
assert reloaded is not None
assert reloaded.delivery.failure_destination is not None
assert reloaded.delivery.failure_destination.mode == DeliveryMode.CHANNEL
assert reloaded.delivery.failure_destination.channel_name == "slack"
assert reloaded.delivery.failure_destination.channel_id == "C-ops"
finally:
await store.close()
async def test_failure_destination_webhook_round_trips(tmp_path: Path) -> None:
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
ops = SchedulerOps(store)
fd = FailureDestination(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/alerts",
webhook_token="ops-secret",
)
delivery = DeliveryConfig(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/primary",
failure_destination=fd,
)
job = await ops.add(
name="job",
schedule_kind=ScheduleKind.CRON,
schedule_value="*/5 * * * *",
handler_key="agent_run",
payload=make_agent_turn_payload("x"),
session_target=SessionTarget.ISOLATED,
delivery=delivery,
)
reloaded = await store.get(job.id)
assert reloaded is not None
assert reloaded.delivery.failure_destination is not None
assert reloaded.delivery.failure_destination.mode == DeliveryMode.WEBHOOK
assert (
reloaded.delivery.failure_destination.webhook_url
== "https://hooks.example/alerts"
)
finally:
await store.close()
# --- RPC wire ----------------------------------------------------------------
class _FakeScheduler:
def __init__(self) -> None:
self.kwargs: dict[str, Any] | None = None
async def add_job(self, **kwargs):
self.kwargs = kwargs
return CronJob(
id="job-fd",
name=kwargs["name"],
cron_expr=kwargs.get("schedule_value") or kwargs.get("schedule_raw", ""),
schedule_raw=kwargs.get("schedule_value") or kwargs.get("schedule_raw", ""),
handler_key=kwargs["handler_key"],
payload=kwargs["payload"],
session_target=kwargs["session_target"],
session_key=kwargs["session_key"],
origin_session_key=kwargs["origin_session_key"],
delivery=kwargs.get("delivery") or DeliveryConfig(),
)
async def update_job(self, job_id, **patch):
return None
async def test_rpc_cron_add_webhook_with_failure_destination_channel() -> None:
scheduler = _FakeScheduler()
await _handle_cron_add(
{
"name": "report",
"expression": "0 9 * * *",
"payloadKind": AGENT_TURN_KIND,
"text": "x",
"sessionTarget": "isolated",
"delivery": {
"mode": "webhook",
"webhookUrl": "https://hooks.example/primary",
"failureDestination": {
"mode": "channel",
"channelName": "slack",
"channelId": "C-ops",
},
},
},
RpcContext(conn_id="t", cron_scheduler=scheduler),
)
delivery = scheduler.kwargs["delivery"]
assert delivery.failure_destination is not None
assert delivery.failure_destination.mode == DeliveryMode.CHANNEL
assert delivery.failure_destination.channel_name == "slack"
assert delivery.failure_destination.channel_id == "C-ops"
async def test_rpc_cron_add_with_failure_destination_webhook() -> None:
scheduler = _FakeScheduler()
await _handle_cron_add(
{
"name": "report",
"expression": "0 9 * * *",
"payloadKind": AGENT_TURN_KIND,
"text": "x",
"sessionTarget": "isolated",
"delivery": {
"mode": "webhook",
"webhookUrl": "https://hooks.example/primary",
"failureDestination": {
"mode": "webhook",
"webhookUrl": "https://hooks.example/alerts",
},
},
},
RpcContext(conn_id="t", cron_scheduler=scheduler),
)
fd = scheduler.kwargs["delivery"].failure_destination
assert fd.mode == DeliveryMode.WEBHOOK
assert fd.webhook_url == "https://hooks.example/alerts"
async def test_rpc_cron_add_failure_destination_webhook_requires_url() -> None:
scheduler = _FakeScheduler()
with pytest.raises(ValueError, match="webhookUrl"):
await _handle_cron_add(
{
"name": "x",
"expression": "0 9 * * *",
"payloadKind": AGENT_TURN_KIND,
"text": "x",
"sessionTarget": "isolated",
"delivery": {
"mode": "webhook",
"webhookUrl": "https://hooks.example/primary",
"failureDestination": {"mode": "webhook"},
},
},
RpcContext(conn_id="t", cron_scheduler=scheduler),
)
def test_job_to_wire_emits_failure_destination() -> None:
job = CronJob(
id="job-1",
name="x",
cron_expr="0 9 * * *",
schedule_raw="0 9 * * *",
handler_key="agent_run",
payload={"kind": AGENT_TURN_KIND, "task": "x", "agent_id": "main"},
session_target=SessionTarget.ISOLATED,
delivery=DeliveryConfig(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/primary",
failure_destination=FailureDestination(
mode=DeliveryMode.CHANNEL,
channel_name="slack",
channel_id="C-ops",
),
),
)
wire = _job_to_wire(job)
assert wire["delivery"]["failureDestination"]["mode"] == "channel"
assert wire["delivery"]["failureDestination"]["channelName"] == "slack"
assert wire["delivery"]["failureDestination"]["channelId"] == "C-ops"
def test_job_to_wire_omits_failure_destination_when_absent() -> None:
job = CronJob(
id="job-1",
name="x",
cron_expr="0 9 * * *",
schedule_raw="0 9 * * *",
handler_key="agent_run",
payload={"kind": AGENT_TURN_KIND, "task": "x", "agent_id": "main"},
session_target=SessionTarget.ISOLATED,
)
wire = _job_to_wire(job)
# Field present but null is the simpler contract for clients.
assert wire["delivery"]["failureDestination"] is None
# --- Failure-destination dispatch on run failure -------------------------
#
# The dispatch hook in scheduler.jobs.execute_with_timeout fires for ANY
# failed run regardless of handler_key (agent_run, system_event, timeout,
# generic exception), so the FD wire contract is honored uniformly.
class _RecordingAdapter:
def __init__(self) -> None:
self.sent: list[Any] = []
async def send(self, msg: Any) -> None:
self.sent.append(msg)
class _RecordingChannelManager:
def __init__(self) -> None:
self.adapters: dict[str, _RecordingAdapter] = {}
def register(self, name: str) -> _RecordingAdapter:
adapter = _RecordingAdapter()
self.adapters[name] = adapter
return adapter
def get(self, name: str) -> _RecordingAdapter | None:
return self.adapters.get(name)
class _RecordingHttpxClient:
posts: list[dict] = []
def __init__(self, *, timeout: float | None = None, **_kw) -> None:
self.timeout = timeout
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def post(self, url: str, json: dict, headers: dict):
_RecordingHttpxClient.posts.append(
{"url": url, "json": json, "headers": headers}
)
class _Resp:
status_code = 200
def raise_for_status(self) -> None:
return None
return _Resp()
def _job_with_delivery(
*,
handler_key: str,
delivery: DeliveryConfig | None,
timeout_seconds: float = 10.0,
) -> CronJob:
payload = (
{"kind": "system_event", "text": "reminder", "agent_id": "main"}
if handler_key == "system_event"
else {"kind": AGENT_TURN_KIND, "task": "x", "agent_id": "main"}
)
target = (
SessionTarget.MAIN if handler_key == "system_event" else SessionTarget.ISOLATED
)
return CronJob(
id="job-fd-route",
name="hook",
cron_expr="*/5 * * * *",
handler_key=handler_key,
payload=payload,
session_target=target,
delivery=delivery or DeliveryConfig(),
timeout_seconds=timeout_seconds,
)
@pytest.fixture
def fd_dispatcher_hook():
"""Install DeliveryChain.dispatch_failure_alert as the global FD dispatcher."""
calls: list[tuple[str, str]] = []
async def _stub(job: CronJob, text: str) -> str:
calls.append((job.id, text))
return "delivered"
set_failure_dispatcher(_stub)
try:
yield calls
finally:
set_failure_dispatcher(None)
async def test_failure_dispatches_fd_for_agent_run_handler(fd_dispatcher_hook) -> None:
"""A failing agent_run handler triggers the FD dispatcher."""
job = _job_with_delivery(
handler_key="agent_run",
delivery=DeliveryConfig(
mode=DeliveryMode.CHANNEL,
channel_name="feishu",
failure_destination=FailureDestination(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/alerts",
),
),
)
async def _failing_handler(job: CronJob):
raise RuntimeError("agent crashed: 401")
execution = await execute_with_timeout(job, _failing_handler)
assert execution.success is False
assert fd_dispatcher_hook == [("job-fd-route", "agent crashed: 401")]
async def test_failure_dispatches_fd_for_system_event_handler(fd_dispatcher_hook) -> None:
"""A failing system_event handler also triggers the FD dispatcher —
the dispatch is handler-agnostic, so non-agent failures are covered."""
job = _job_with_delivery(
handler_key="system_event",
delivery=DeliveryConfig(
mode=DeliveryMode.NONE,
failure_destination=FailureDestination(
mode=DeliveryMode.CHANNEL,
channel_name="slack",
channel_id="C-ops",
),
),
)
async def _failing_handler(job: CronJob):
raise RuntimeError("heartbeat failed")
execution = await execute_with_timeout(job, _failing_handler)
assert execution.success is False
assert fd_dispatcher_hook == [("job-fd-route", "heartbeat failed")]
async def test_failure_dispatches_fd_on_timeout(fd_dispatcher_hook) -> None:
"""A handler timeout also routes through the FD dispatcher."""
import asyncio
job = _job_with_delivery(
handler_key="agent_run",
delivery=DeliveryConfig(
failure_destination=FailureDestination(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/alerts",
),
),
timeout_seconds=0.05,
)
async def _slow_handler(job: CronJob):
await asyncio.sleep(5.0)
execution = await execute_with_timeout(job, _slow_handler)
assert execution.success is False
assert "Timeout" in (execution.error or "")
assert len(fd_dispatcher_hook) == 1
assert fd_dispatcher_hook[0][0] == "job-fd-route"
async def test_success_does_not_dispatch_fd(fd_dispatcher_hook) -> None:
"""Successful runs never invoke the FD dispatcher."""
job = _job_with_delivery(
handler_key="agent_run",
delivery=DeliveryConfig(
failure_destination=FailureDestination(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/alerts",
),
),
)
async def _ok_handler(job: CronJob):
return "all good"
execution = await execute_with_timeout(job, _ok_handler)
assert execution.success is True
assert fd_dispatcher_hook == []
async def test_failure_without_failure_destination_skips_dispatch(
fd_dispatcher_hook,
) -> None:
"""A failed run with no FD configured does NOT invoke the dispatcher."""
job = _job_with_delivery(
handler_key="agent_run",
delivery=DeliveryConfig(failure_destination=None),
)
async def _failing_handler(job: CronJob):
raise RuntimeError("oops")
execution = await execute_with_timeout(job, _failing_handler)
assert execution.success is False
assert fd_dispatcher_hook == []
async def test_dispatcher_exception_does_not_break_state_machine() -> None:
"""A crashing FD dispatcher must not corrupt the execution record."""
async def _boom(job: CronJob, text: str):
raise RuntimeError("dispatcher exploded")
set_failure_dispatcher(_boom)
try:
job = _job_with_delivery(
handler_key="agent_run",
delivery=DeliveryConfig(
failure_destination=FailureDestination(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/alerts",
),
),
)
async def _failing_handler(job: CronJob):
raise RuntimeError("real failure")
execution = await execute_with_timeout(job, _failing_handler)
# Original failure preserved; dispatcher crash swallowed.
assert execution.success is False
assert "real failure" in (execution.error or "")
finally:
set_failure_dispatcher(None)
# --- DeliveryChain.dispatch_failure_alert (the wired implementation) -----
async def test_dispatch_failure_alert_via_webhook(monkeypatch) -> None:
_RecordingHttpxClient.posts.clear()
monkeypatch.setitem(
sys.modules, "httpx", type("M", (), {"AsyncClient": _RecordingHttpxClient})
)
chain = DeliveryChain()
job = _job_with_delivery(
handler_key="agent_run",
delivery=DeliveryConfig(
failure_destination=FailureDestination(
mode=DeliveryMode.WEBHOOK,
webhook_url="https://hooks.example/alerts",
webhook_token="ops-secret",
),
),
)
status = await chain.dispatch_failure_alert(job, "agent failed: timeout")
assert status == "delivered"
assert len(_RecordingHttpxClient.posts) == 1
post = _RecordingHttpxClient.posts[-1]
assert post["url"] == "https://hooks.example/alerts"
assert post["headers"]["Authorization"] == "Bearer ops-secret"
assert post["json"]["summary"] == "agent failed: timeout"
async def test_dispatch_failure_alert_via_channel() -> None:
cm = _RecordingChannelManager()
cm.register("slack")
chain = DeliveryChain(channel_manager_ref=lambda: cm)
job = _job_with_delivery(
handler_key="system_event",
delivery=DeliveryConfig(
failure_destination=FailureDestination(
mode=DeliveryMode.CHANNEL,
channel_name="slack",
channel_id="C-ops",
),
),
)
status = await chain.dispatch_failure_alert(job, "heartbeat failed")
assert status == "delivered"
assert len(cm.adapters["slack"].sent) == 1
assert cm.adapters["slack"].sent[-1].content == "heartbeat failed"
async def test_dispatch_failure_alert_no_fd_is_skip() -> None:
chain = DeliveryChain()
job = _job_with_delivery(handler_key="agent_run", delivery=DeliveryConfig())
assert await chain.dispatch_failure_alert(job, "x") == "skipped"
# --- Gateway boot wiring contract ---------------------------------------
async def test_set_failure_dispatcher_with_delivery_chain_drives_end_to_end() -> None:
"""Locks the contract gateway boot relies on: registering
``DeliveryChain.dispatch_failure_alert`` via ``set_failure_dispatcher``
causes any failed cron run to land on the configured FailureDestination.
Regression guard for the boot-time wire in gateway/boot.py."""
cm = _RecordingChannelManager()
cm.register("slack")
chain = DeliveryChain(channel_manager_ref=lambda: cm)
set_failure_dispatcher(chain.dispatch_failure_alert)
try:
job = _job_with_delivery(
handler_key="system_event",
delivery=DeliveryConfig(
failure_destination=FailureDestination(
mode=DeliveryMode.CHANNEL,
channel_name="slack",
channel_id="C-ops",
),
),
)
async def _failing_handler(job: CronJob):
raise RuntimeError("heartbeat dead")
execution = await execute_with_timeout(job, _failing_handler)
assert execution.success is False
# End-to-end: hook → DeliveryChain.dispatch_failure_alert →
# _post_to_channel → adapter.send. The slack FD adapter sees the alert.
assert len(cm.adapters["slack"].sent) == 1
assert cm.adapters["slack"].sent[-1].content == "heartbeat dead"
finally:
set_failure_dispatcher(None)