314dc03b0d
Moves the orchestrator into bot_bottle/orchestrator/ so one install gets everything. Entry point is now `python -m bot_bottle.orchestrator run`. - Add bot_bottle/orchestrator/ with all 14 modules (verbatim move; internal imports were already relative, so no changes inside orchestrator modules) - Rewrite bootstrap.py: remove the lazy bot_bottle import guard, use direct relative imports from ..contrib.* - Add bot_bottle/contrib/forge/base.py: ScopedForge (read-anywhere / write-scoped) - Add bot_bottle/contrib/gitea/client.py: GiteaClient + GiteaForge (urllib.request only) - Add bot_bottle/contrib/gitea/forge_state.py: ForgeState + SqliteForgeStateStore - Add tests/unit/orchestrator/ (82 tests: 63 migrated + 19 new for contrib modules) Closes #321
67 lines
2.2 KiB
Python
67 lines
2.2 KiB
Python
"""Unit: watchdog sweep."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timedelta
|
|
|
|
from bot_bottle.orchestrator.model import STATUS_FROZEN, STATUS_RUNNING, RunRecord
|
|
from bot_bottle.orchestrator.store import InMemoryStateStore
|
|
from bot_bottle.orchestrator.watchdog import Watchdog
|
|
|
|
from ._fakes import FakeRunner
|
|
|
|
_NOW = datetime(2026, 7, 1, 12, 0, 0).astimezone()
|
|
|
|
|
|
def _record(issue: int, status: str, checkin: str) -> RunRecord:
|
|
return RunRecord(
|
|
owner="o", repo="r", issue_number=issue, slug=f"s{issue}",
|
|
agent_name="a", status=status, last_checkin_at=checkin,
|
|
)
|
|
|
|
|
|
class WatchdogSweepTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.store = InMemoryStateStore()
|
|
self.runner = FakeRunner()
|
|
self.wd = Watchdog(store=self.store, runner=self.runner, timeout_secs=1800)
|
|
|
|
def _status(self, issue: int) -> str:
|
|
rec = self.store.get("o", "r", issue)
|
|
assert rec is not None
|
|
return rec.status
|
|
|
|
def test_stale_running_is_frozen(self):
|
|
stale = (_NOW - timedelta(minutes=31)).isoformat()
|
|
self.store.upsert(_record(1, STATUS_RUNNING, stale))
|
|
fired = self.wd.sweep(_NOW)
|
|
self.assertEqual([1], [r.issue_number for r in fired])
|
|
self.assertEqual(STATUS_FROZEN, self._status(1))
|
|
self.assertIn(("freeze", "s1"), self.runner.calls)
|
|
|
|
def test_fresh_running_untouched(self):
|
|
fresh = (_NOW - timedelta(minutes=5)).isoformat()
|
|
self.store.upsert(_record(2, STATUS_RUNNING, fresh))
|
|
self.assertEqual([], self.wd.sweep(_NOW))
|
|
self.assertEqual(STATUS_RUNNING, self._status(2))
|
|
|
|
def test_non_running_ignored(self):
|
|
stale = (_NOW - timedelta(hours=2)).isoformat()
|
|
self.store.upsert(_record(3, STATUS_FROZEN, stale))
|
|
self.assertEqual([], self.wd.sweep(_NOW))
|
|
|
|
def test_unparseable_checkin_skipped(self):
|
|
self.store.upsert(_record(4, STATUS_RUNNING, "not-a-time"))
|
|
self.assertEqual([], self.wd.sweep(_NOW))
|
|
|
|
def test_start_and_stop(self):
|
|
# Exercises the daemon-thread start/stop path; stop sets the event
|
|
# so the loop's wait returns immediately.
|
|
self.wd.start()
|
|
self.wd.stop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|