mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-13 04:01:35 +00:00
131 lines
5.5 KiB
Python
131 lines
5.5 KiB
Python
"""Daily scheduling, process ownership, and shutdown without real model or network calls."""
|
|
|
|
# pylint: disable=missing-function-docstring,redefined-outer-name,protected-access
|
|
|
|
import asyncio
|
|
import importlib
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
PLUGIN = Path(__file__).resolve().parents[2] / "integrations/codex/plugins/reme"
|
|
|
|
|
|
@pytest.fixture
|
|
def runtime(tmp_path, monkeypatch):
|
|
monkeypatch.syspath_prepend(str(PLUGIN))
|
|
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex"))
|
|
return importlib.import_module("reme_runtime"), importlib.import_module("reme_config")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("cron", "zone", "now", "expected"),
|
|
[
|
|
("0 23 * * *", "Asia/Shanghai", "2026-09-30T10:00:00+00:00", "2026-09-30T15:00:00+00:00"),
|
|
("0 23 * * *", "Asia/Shanghai", "2026-09-30T15:00:00+00:00", "2026-10-01T15:00:00+00:00"),
|
|
("30 2 * * *", "America/New_York", "2026-03-08T06:59:00+00:00", "2026-03-09T06:30:00+00:00"),
|
|
("30 1 * * *", "America/New_York", "2026-11-01T04:00:00+00:00", "2026-11-01T05:30:00+00:00"),
|
|
("30 1 * * *", "America/New_York", "2026-11-01T05:45:00+00:00", "2026-11-02T06:30:00+00:00"),
|
|
],
|
|
)
|
|
def test_daily_schedule_handles_dst_once_per_local_day(runtime, cron, zone, now, expected):
|
|
module, _ = runtime
|
|
assert module.next_daily_run(cron, zone, datetime.fromisoformat(now)).isoformat() == expected
|
|
|
|
|
|
async def wait_until(predicate):
|
|
async with asyncio.timeout(3):
|
|
while not predicate():
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_scheduler_runs_slot_once_and_fails_over(runtime, monkeypatch):
|
|
module, config = runtime
|
|
config.update_settings({"dreamHint": "retain decisions"})
|
|
due = datetime.now(timezone.utc) + timedelta(milliseconds=50)
|
|
next_times = iter([due, due + timedelta(days=1), due + timedelta(days=1)])
|
|
monkeypatch.setattr(module, "next_daily_run", lambda *_: next(next_times))
|
|
call = AsyncMock(return_value={"answer": "done"})
|
|
monkeypatch.setattr(module, "call_async", call)
|
|
first, second = module.ReMeRuntime(), module.ReMeRuntime()
|
|
await first.start()
|
|
await second.start()
|
|
try:
|
|
await wait_until(lambda: call.call_count == 1)
|
|
assert call.call_args.args[1] == "auto_dream"
|
|
assert call.call_args.args[2]["hint"] == "retain decisions"
|
|
assert call.call_args.args[3] == 3600
|
|
receipt = module.endpoint_dir(config.load_config()) / "dream_receipt.json"
|
|
assert json.loads(receipt.read_text())["scheduled_at"] == due.isoformat()
|
|
await module.execute_dream(config.load_config(), origin="scheduled", scheduled_at=due.isoformat())
|
|
assert call.call_count == 1
|
|
await first.close()
|
|
state = config.data_dir() / "scheduler.json"
|
|
await wait_until(lambda: json.loads(state.read_text()).get("phase") == "running")
|
|
assert call.call_count == 1
|
|
finally:
|
|
await first.close()
|
|
await second.close()
|
|
assert first._task is second._task is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_settings_reschedule_and_disable_without_starting_a_service(runtime):
|
|
module, config = runtime
|
|
config.update_settings({"autoDreamEnabled": False})
|
|
instance = module.ReMeRuntime()
|
|
await instance.start()
|
|
state = config.data_dir() / "scheduler.json"
|
|
try:
|
|
await wait_until(state.exists)
|
|
assert json.loads(state.read_text())["next_run_at"] is None
|
|
config.update_settings({"autoDreamEnabled": True, "dreamCron": "45 6 * * *", "timezone": "UTC"})
|
|
instance.settings_changed()
|
|
await wait_until(lambda: json.loads(state.read_text()).get("cron") == "45 6 * * *")
|
|
assert json.loads(state.read_text())["next_run_at"].endswith("06:45:00+00:00")
|
|
config.update_settings({"autoDreamEnabled": False})
|
|
instance.settings_changed()
|
|
await wait_until(lambda: json.loads(state.read_text()).get("enabled") is False)
|
|
assert json.loads(state.read_text())["next_run_at"] is None
|
|
finally:
|
|
await instance.close()
|
|
assert json.loads(state.read_text())["phase"] == "stopped"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_cancels_scheduled_call_and_releases_locks(runtime, monkeypatch):
|
|
module, config = runtime
|
|
config.update_settings({"shutdownTimeoutMs": 100})
|
|
started = asyncio.Event()
|
|
|
|
async def long_call(*_args):
|
|
started.set()
|
|
await asyncio.Future()
|
|
|
|
monkeypatch.setattr(module, "call_async", long_call)
|
|
monkeypatch.setattr(module, "next_daily_run", lambda *_: datetime.now(timezone.utc))
|
|
instance = module.ReMeRuntime()
|
|
await instance.start()
|
|
await asyncio.wait_for(started.wait(), 2)
|
|
await instance.close()
|
|
state = module.endpoint_dir(config.load_config())
|
|
assert json.loads((state / "dream.json").read_text())["status"] == "cancelled"
|
|
with config.lock(state / "dream.lock") as acquired:
|
|
assert acquired
|
|
with config.lock(config.data_dir() / "scheduler.lock") as acquired:
|
|
assert acquired
|
|
|
|
|
|
@pytest.mark.parametrize("cron", ["* * * * *", "60 0 * * *", "0 24 * * *", "0 1 * * 1", "0 1 * * * extra"])
|
|
def test_settings_reject_unsupported_schedules_without_overwriting(runtime, cron):
|
|
_, config = runtime
|
|
config.update_settings({"dreamCron": "15 8 * * *"})
|
|
path = config.data_dir() / "config.json"
|
|
before = path.read_bytes()
|
|
with pytest.raises(ValueError):
|
|
config.update_settings({"dreamCron": cron})
|
|
assert path.read_bytes() == before
|