mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 05:50:44 +08:00
Due ticks no longer stack on an active run (WA skip notice + stale clear); schedule_list/admin/gateway now show playbook, steps_n, and last_run signals without dumping full recipes. Co-authored-by: Cursor <cursoragent@cursor.com>
186 lines
7.3 KiB
Python
186 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from runtime.scheduler.service import enqueue_scheduled_job_run
|
|
from runtime.scheduler.turn_text import format_scheduled_skip_summary
|
|
from runtime.tools.experts.productivity.schedule_tools import schedule_list_tool
|
|
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
|
from svc.persistence.sqlite_store import SqliteStore
|
|
|
|
|
|
class OverlapSkipTextTests(unittest.TestCase):
|
|
def test_skip_summary_english(self) -> None:
|
|
text = format_scheduled_skip_summary(
|
|
job_name="Hourly congestion",
|
|
overlapping_run_id="abcdef12-3456",
|
|
lang="en",
|
|
)
|
|
self.assertIn("[Scheduled job skipped]", text)
|
|
self.assertIn("overlapping", text)
|
|
self.assertIn("abcdef12-345", text)
|
|
|
|
|
|
class ScheduledOverlapAndListTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
|
self.db = Path(self._tmp.name) / "overlap.sqlite"
|
|
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
|
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
|
reset_assistant_store_singleton()
|
|
self.store = SqliteStore(str(self.db))
|
|
t = self.store.create_tenant("Team")
|
|
self.tenant_id = str(t["id"])
|
|
user = self.store.create_user_account(
|
|
tenant_id=self.tenant_id,
|
|
username="administrator",
|
|
display_name="Admin",
|
|
role="owner",
|
|
password_hash="x",
|
|
is_active=True,
|
|
)
|
|
self.user_id = str(user["id"])
|
|
|
|
def tearDown(self) -> None:
|
|
reset_assistant_store_singleton()
|
|
self._tmp.cleanup()
|
|
|
|
def _make_job(self, *, recipe: dict | None = None, prompt: str = "remind drink"):
|
|
return self.store.scheduled_job_create(
|
|
tenant_id=self.tenant_id,
|
|
name="job-a",
|
|
prompt_text=prompt,
|
|
schedule_kind="interval",
|
|
schedule_expr="3600",
|
|
timezone_name="UTC",
|
|
lang="en",
|
|
delivery={"whatsapp": {"enabled": True, "target_type": "group", "chat_id": "x@g.us"}},
|
|
recipe=recipe or {},
|
|
created_by_user_id=self.user_id,
|
|
)
|
|
|
|
def test_enqueue_skips_when_prior_run_active(self) -> None:
|
|
job = self._make_job()
|
|
active = self.store.scheduled_job_run_create(
|
|
job_id=job.id,
|
|
tenant_id=self.tenant_id,
|
|
status="running",
|
|
)
|
|
self.store.scheduled_job_run_update(
|
|
run_id=active.id,
|
|
tenant_id=self.tenant_id,
|
|
patch={"status": "running", "started_at": "2026-08-10T23:00:00+00:00"},
|
|
)
|
|
|
|
with patch(
|
|
"runtime.scheduler.service.deliver_scheduled_reply",
|
|
create=True,
|
|
):
|
|
# Patch via channel_delivery import path used inside helper.
|
|
with patch(
|
|
"runtime.scheduler.channel_delivery.deliver_scheduled_reply",
|
|
return_value={"ok": True, "channel": "whatsapp"},
|
|
) as deliver:
|
|
out = enqueue_scheduled_job_run(self.store, job=job, mode="scheduled")
|
|
|
|
self.assertTrue(out.get("ok"), out)
|
|
self.assertTrue(out.get("skipped"), out)
|
|
self.assertEqual(out.get("reason"), "overlapping_run")
|
|
self.assertEqual(out.get("overlapping_run_id"), active.id)
|
|
deliver.assert_called_once()
|
|
skipped = self.store.scheduled_job_run_get(run_id=str(out["run_id"]), tenant_id=self.tenant_id)
|
|
assert skipped is not None
|
|
self.assertEqual(skipped.status, "skipped")
|
|
self.assertIn("overlapping", skipped.reply_text.lower())
|
|
refreshed = self.store.scheduled_job_get(job_id=job.id, tenant_id=self.tenant_id)
|
|
assert refreshed is not None
|
|
self.assertEqual(refreshed.last_run_status, "skipped")
|
|
|
|
def test_force_overlap_bypasses_skip(self) -> None:
|
|
job = self._make_job()
|
|
active = self.store.scheduled_job_run_create(
|
|
job_id=job.id,
|
|
tenant_id=self.tenant_id,
|
|
status="running",
|
|
)
|
|
self.store.scheduled_job_run_update(
|
|
run_id=active.id,
|
|
tenant_id=self.tenant_id,
|
|
patch={"status": "running", "started_at": "2026-08-10T23:00:00+00:00"},
|
|
)
|
|
|
|
from runtime.scheduler import service as sched_service
|
|
from runtime.scheduler.session_resolver import ResolvedSession
|
|
|
|
resolved = ResolvedSession(
|
|
session_id="sess-1",
|
|
tenant_id=self.tenant_id,
|
|
user_id=self.user_id,
|
|
channel="admin_chat",
|
|
account_id="",
|
|
external_chat_id="",
|
|
external_user_id="",
|
|
is_group=False,
|
|
)
|
|
self.store.oclaw_task_create = MagicMock(return_value=MagicMock(id="task-1")) # type: ignore[method-assign]
|
|
original_resolve = sched_service.resolve_scheduled_session
|
|
original_worker = sched_service.ensure_worker_started
|
|
try:
|
|
sched_service.resolve_scheduled_session = MagicMock(return_value=resolved)
|
|
sched_service.ensure_worker_started = MagicMock(return_value="worker-1")
|
|
out = enqueue_scheduled_job_run(self.store, job=job, mode="manual", force_overlap=True)
|
|
finally:
|
|
sched_service.resolve_scheduled_session = original_resolve
|
|
sched_service.ensure_worker_started = original_worker
|
|
|
|
self.assertTrue(out.get("ok"), out)
|
|
self.assertFalse(bool(out.get("skipped")))
|
|
self.assertIn("task_id", out)
|
|
|
|
def test_schedule_list_exposes_playbook_signals(self) -> None:
|
|
recipe = {
|
|
"goal": "Hourly bandwidth check",
|
|
"steps": ["Query alarms", "Filter areas", "Deliver xlsx"],
|
|
"success_criteria": ["Report sent"],
|
|
}
|
|
self._make_job(recipe=recipe, prompt="Hourly bandwidth check")
|
|
self._make_job(prompt="提醒喝水")
|
|
|
|
out = schedule_list_tool().handler(
|
|
{"tenant_id": self.tenant_id, "owner_user_id": self.user_id, "limit": 20}
|
|
)
|
|
self.assertTrue(out.get("ok"), out)
|
|
items = out.get("items") or []
|
|
self.assertGreaterEqual(len(items), 2)
|
|
playbook_items = [i for i in items if i.get("playbook")]
|
|
self.assertTrue(playbook_items)
|
|
hit = playbook_items[0]
|
|
self.assertEqual(hit.get("steps_n"), 3)
|
|
self.assertIn("Hourly bandwidth", str(hit.get("recipe_goal") or ""))
|
|
self.assertNotIn("recipe", hit)
|
|
|
|
reminder = next(i for i in items if not i.get("playbook") and not i.get("has_recipe"))
|
|
self.assertEqual(int(reminder.get("steps_n") or 0), 0)
|
|
|
|
def test_job_to_dict_includes_summary(self) -> None:
|
|
job = self._make_job(
|
|
recipe={
|
|
"goal": "License report",
|
|
"steps": ["Query", "Attach"],
|
|
"success_criteria": ["Done"],
|
|
},
|
|
prompt="License report",
|
|
)
|
|
d = self.store.scheduled_job_to_dict(job)
|
|
self.assertTrue(d.get("playbook"))
|
|
self.assertEqual(d.get("steps_n"), 2)
|
|
self.assertTrue(d.get("has_recipe"))
|
|
self.assertIn("License", str(d.get("recipe_goal") or ""))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|