fix: harden cron session automation flows
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
|
||||
|
||||
class DummyProvider(LLMProvider):
|
||||
|
||||
@@ -639,7 +639,7 @@ async def test_cron_turn_deferred_while_session_active(tmp_path):
|
||||
chat_id="chat-1",
|
||||
content="scheduled work",
|
||||
metadata={
|
||||
CRON_TRIGGER_META: {"run_id": "run-1"},
|
||||
CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"},
|
||||
CRON_DEFER_UNTIL_IDLE_META: True,
|
||||
},
|
||||
session_key_override=session_key,
|
||||
@@ -657,11 +657,49 @@ async def test_cron_turn_deferred_while_session_active(tmp_path):
|
||||
assert pending.empty()
|
||||
assert loop._dispatch.await_count == 0
|
||||
assert loop._cron_turns.deferred_queues[session_key] == [msg]
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
||||
|
||||
await loop._cron_turns.publish_next_deferred(session_key)
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
assert queued is msg
|
||||
assert session_key not in loop._cron_turns.deferred_queues
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submitted_cron_turn_reports_pending_until_completed(tmp_path):
|
||||
"""Bound cron jobs remain marked pending while their session turn is in flight."""
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.cron.session_turns import CRON_TRIGGER_META
|
||||
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._running = True
|
||||
|
||||
session_key = "websocket:chat-1"
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="cron",
|
||||
chat_id="chat-1",
|
||||
content="scheduled work",
|
||||
metadata={CRON_TRIGGER_META: {"job_id": "job-1", "run_id": "run-1"}},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
|
||||
submit_task = asyncio.create_task(loop.submit_cron_turn(msg))
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
|
||||
assert queued is msg
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
||||
|
||||
response = OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="done",
|
||||
)
|
||||
loop._cron_turns.complete(msg, response=response)
|
||||
|
||||
assert await asyncio.wait_for(submit_task, timeout=0.5) is response
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -30,6 +30,7 @@ def _make_handler(
|
||||
workspace_path: Path | None = None,
|
||||
runtime_model_name: Any | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
) -> GatewayServices:
|
||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||
workspace = workspace_path or Path.cwd()
|
||||
@@ -44,6 +45,7 @@ def _make_handler(
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
cron_service=cron_service,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
)
|
||||
|
||||
|
||||
@@ -56,6 +58,7 @@ def _ch(
|
||||
port: int = _PORT,
|
||||
runtime_model_name: Any | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
**extra: Any,
|
||||
) -> WebSocketChannel:
|
||||
cfg: dict[str, Any] = {
|
||||
@@ -74,6 +77,7 @@ def _ch(
|
||||
workspace_path=workspace_path,
|
||||
runtime_model_name=runtime_model_name,
|
||||
cron_service=cron_service,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
@@ -177,11 +181,12 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
) -> None:
|
||||
cron = CronService(tmp_path / "cron" / "jobs.json")
|
||||
hourly = CronSchedule(kind="every", every_ms=3_600_000)
|
||||
pending_job_id = ""
|
||||
for name, message, to in (
|
||||
("Morning check", "Check the project status", "abc"),
|
||||
("Other session", "Do not show", "other"),
|
||||
):
|
||||
cron.add_job(
|
||||
job = cron.add_job(
|
||||
name=name,
|
||||
schedule=hourly,
|
||||
message=message,
|
||||
@@ -189,6 +194,8 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
origin_channel="websocket",
|
||||
origin_chat_id=to,
|
||||
)
|
||||
if name == "Morning check":
|
||||
pending_job_id = job.id
|
||||
cron.add_job(
|
||||
name="Legacy same target",
|
||||
schedule=hourly,
|
||||
@@ -210,6 +217,7 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path, key="websocket:abc"),
|
||||
cron_service=cron,
|
||||
cron_pending_job_ids=lambda key: {pending_job_id} if key == "websocket:abc" else set(),
|
||||
port=29914,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
@@ -235,6 +243,8 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
assert job["schedule"]["kind"] == "every"
|
||||
assert job["schedule"]["every_ms"] == 3_600_000
|
||||
assert job["payload"]["message"] == "Check the project status"
|
||||
assert job["state"]["pending"] is True
|
||||
assert body["jobs"][1]["state"]["pending"] is False
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -11,6 +11,7 @@ from typer.testing import CliRunner
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.cron.service import CronJobSkippedError
|
||||
from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
||||
@@ -1305,9 +1306,9 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
||||
),
|
||||
)
|
||||
|
||||
response = asyncio.run(cron.on_job(job))
|
||||
with pytest.raises(CronJobSkippedError, match="unbound agent cron job"):
|
||||
asyncio.run(cron.on_job(job))
|
||||
|
||||
assert response is None
|
||||
bus.publish_outbound.assert_not_awaited()
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import time
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
|
||||
|
||||
@@ -296,6 +296,30 @@ async def test_run_history_records_errors(tmp_path) -> None:
|
||||
assert loaded.state.run_history[0].error == "boom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_history_records_skipped_jobs(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
async def skip(_):
|
||||
raise CronJobSkippedError("missing session binding")
|
||||
|
||||
service = CronService(store_path, on_job=skip)
|
||||
job = service.add_job(
|
||||
name="skip",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
)
|
||||
await service.run_job(job.id)
|
||||
|
||||
loaded = service.get_job(job.id)
|
||||
assert loaded is not None
|
||||
assert loaded.state.last_status == "skipped"
|
||||
assert loaded.state.last_error == "missing session binding"
|
||||
assert len(loaded.state.run_history) == 1
|
||||
assert loaded.state.run_history[0].status == "skipped"
|
||||
assert loaded.state.run_history[0].error == "missing session binding"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_history_records_job_cancellation(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
Reference in New Issue
Block a user