233 lines
8.6 KiB
Python
233 lines
8.6 KiB
Python
"""Fix 5 PR7 — kanban queue-depth surface + stuck-session alerts.
|
|
|
|
Covers:
|
|
- Kanban payload surfacing: ``_serialize_session`` includes
|
|
``pending_queue_depth`` + ``pending_work_item_ids``.
|
|
- Stuck-queue runtime event fires at the depth threshold (once, not
|
|
on every subsequent enqueue).
|
|
- ``check_stuck_focused_sessions`` emits one event per offender whose
|
|
focus has been held past ``threshold_minutes``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from opc.core.models import (
|
|
CompanyMemberSession,
|
|
DelegationRoleSession,
|
|
DelegationRun,
|
|
DelegationWorkItem,
|
|
Phase,
|
|
)
|
|
from opc.database.store import OPCStore
|
|
from opc.layer2_organization import phase_hooks # noqa: F401 — registers hooks
|
|
from opc.layer2_organization.phase_hooks import (
|
|
STUCK_QUEUE_DEPTH_THRESHOLD,
|
|
check_stuck_focused_sessions,
|
|
)
|
|
|
|
|
|
# ── Kanban payload surfacing ─────────────────────────────────────────────
|
|
|
|
|
|
class SerializeSessionSurfacesQueueDepthTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
from opc.layer2_organization.company_runtime import CompanyRuntime
|
|
|
|
async def _noop(*_a, **_kw):
|
|
return None
|
|
|
|
self.runtime = CompanyRuntime(
|
|
org_engine=None,
|
|
communication=None,
|
|
store=None,
|
|
save_runtime_session=_noop,
|
|
emit_runtime_event=_noop,
|
|
)
|
|
self.role_session_id = "role-runtime::run::cto"
|
|
self.role_session = DelegationRoleSession(
|
|
role_session_id=self.role_session_id,
|
|
run_id="run", role_id="cto",
|
|
pending_work_item_ids=["wi-a", "wi-b", "wi-c"],
|
|
)
|
|
self.runtime.role_sessions[self.role_session_id] = self.role_session
|
|
self.member = CompanyMemberSession(
|
|
member_session_id="m1",
|
|
role_session_id=self.role_session_id,
|
|
role_id="cto",
|
|
)
|
|
|
|
def test_serialize_includes_queue_depth_and_ids(self) -> None:
|
|
payload = self.runtime._serialize_session(self.member)
|
|
self.assertEqual(payload["pending_queue_depth"], 3)
|
|
self.assertEqual(
|
|
payload["pending_work_item_ids"], ["wi-a", "wi-b", "wi-c"]
|
|
)
|
|
|
|
def test_serialize_with_no_role_session_returns_empty_queue(self) -> None:
|
|
orphan = CompanyMemberSession(
|
|
member_session_id="m2",
|
|
role_session_id="role-runtime::run::unknown",
|
|
role_id="unknown",
|
|
)
|
|
payload = self.runtime._serialize_session(orphan)
|
|
self.assertEqual(payload["pending_queue_depth"], 0)
|
|
self.assertEqual(payload["pending_work_item_ids"], [])
|
|
|
|
|
|
# ── Stuck-queue runtime event ────────────────────────────────────────────
|
|
|
|
|
|
class StuckQueueRuntimeEventTests(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self) -> None:
|
|
self._tmpdir = tempfile.TemporaryDirectory()
|
|
self.store = OPCStore(db_path=Path(self._tmpdir.name) / "store.db")
|
|
await self.store.initialize()
|
|
self.store.role_serial_queue_enabled = True
|
|
self.sid = "role-runtime::run-pr7::cto"
|
|
await self.store.save_delegation_role_session(
|
|
DelegationRoleSession(
|
|
role_session_id=self.sid,
|
|
run_id="run-pr7",
|
|
role_id="cto",
|
|
focused_work_item_id="wi-busy",
|
|
status="running",
|
|
)
|
|
)
|
|
|
|
async def asyncTearDown(self) -> None:
|
|
await self.store.close()
|
|
self._tmpdir.cleanup()
|
|
|
|
async def _enqueue_wid(self, wid: str) -> None:
|
|
item = DelegationWorkItem(
|
|
work_item_id=wid,
|
|
run_id="run-pr7", cell_id="c",
|
|
role_id="cto", seat_id="s",
|
|
role_runtime_session_id=self.sid,
|
|
manager_role_id="ceo", manager_seat_id="seat::ceo",
|
|
title="t", phase=Phase.READY,
|
|
)
|
|
await self.store.save_delegation_work_item(item)
|
|
|
|
async def _count_events(self, event_type: str) -> int:
|
|
async with self.store._db.execute(
|
|
"SELECT COUNT(*) FROM runtime_events WHERE event_type=?",
|
|
(event_type,),
|
|
) as cursor:
|
|
(count,) = await cursor.fetchone()
|
|
return count
|
|
|
|
async def test_event_fires_exactly_at_threshold_crossing(self) -> None:
|
|
# Enqueue threshold items — only the one that takes us TO the
|
|
# threshold should fire the event.
|
|
for i in range(STUCK_QUEUE_DEPTH_THRESHOLD):
|
|
await self._enqueue_wid(f"wi-{i}")
|
|
self.assertEqual(
|
|
await self._count_events("stuck_session_queue_depth"), 1
|
|
)
|
|
|
|
async def test_event_does_not_fire_below_threshold(self) -> None:
|
|
for i in range(STUCK_QUEUE_DEPTH_THRESHOLD - 1):
|
|
await self._enqueue_wid(f"wi-{i}")
|
|
self.assertEqual(
|
|
await self._count_events("stuck_session_queue_depth"), 0
|
|
)
|
|
|
|
async def test_event_does_not_refire_past_threshold(self) -> None:
|
|
# Enqueue enough to cross the threshold, then another. The hook
|
|
# fires only at the crossing (``depth == threshold``), so
|
|
# subsequent enqueues do not re-fire.
|
|
for i in range(STUCK_QUEUE_DEPTH_THRESHOLD + 2):
|
|
await self._enqueue_wid(f"wi-{i}")
|
|
self.assertEqual(
|
|
await self._count_events("stuck_session_queue_depth"), 1
|
|
)
|
|
|
|
|
|
# ── Stuck-focus sweep ────────────────────────────────────────────────────
|
|
|
|
|
|
class CheckStuckFocusedSessionsTests(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self) -> None:
|
|
self._tmpdir = tempfile.TemporaryDirectory()
|
|
self.store = OPCStore(db_path=Path(self._tmpdir.name) / "store.db")
|
|
await self.store.initialize()
|
|
self.run_id = "run-pr7-stuck"
|
|
# DelegationRun has to exist so list_delegation_runs returns it.
|
|
await self.store.save_delegation_run(
|
|
DelegationRun(
|
|
run_id=self.run_id,
|
|
project_id="proj1",
|
|
session_id="sess",
|
|
)
|
|
)
|
|
|
|
async def asyncTearDown(self) -> None:
|
|
await self.store.close()
|
|
self._tmpdir.cleanup()
|
|
|
|
async def _seed_session(
|
|
self,
|
|
*,
|
|
role_id: str,
|
|
focused: str,
|
|
minutes_ago: int,
|
|
) -> str:
|
|
sid = f"role-runtime::{self.run_id}::{role_id}"
|
|
session = DelegationRoleSession(
|
|
role_session_id=sid,
|
|
run_id=self.run_id,
|
|
role_id=role_id,
|
|
focused_work_item_id=focused,
|
|
status="running",
|
|
)
|
|
# Backdate updated_at so the session looks stale.
|
|
await self.store.save_delegation_role_session(session)
|
|
backdated = (datetime.now() - timedelta(minutes=minutes_ago)).isoformat()
|
|
for table in ("role_runtime_sessions", "delegation_role_sessions"):
|
|
await self.store._db.execute(
|
|
f"UPDATE {table} SET updated_at = ? WHERE role_session_id = ?",
|
|
(backdated, sid),
|
|
)
|
|
await self.store._db.commit()
|
|
return sid
|
|
|
|
async def test_stuck_session_emits_event(self) -> None:
|
|
await self._seed_session(role_id="cto", focused="wi-x", minutes_ago=20)
|
|
emitted = await check_stuck_focused_sessions(
|
|
self.store, run_id=self.run_id, threshold_minutes=10,
|
|
)
|
|
self.assertEqual(len(emitted), 1)
|
|
self.assertEqual(emitted[0]["focused_work_item_id"], "wi-x")
|
|
self.assertGreaterEqual(emitted[0]["focused_for_minutes"], 10)
|
|
# Event row persisted.
|
|
async with self.store._db.execute(
|
|
"SELECT COUNT(*) FROM runtime_events WHERE event_type=?",
|
|
("stuck_session_focused",),
|
|
) as cursor:
|
|
(count,) = await cursor.fetchone()
|
|
self.assertEqual(count, 1)
|
|
|
|
async def test_fresh_session_does_not_emit(self) -> None:
|
|
await self._seed_session(role_id="cmo", focused="wi-y", minutes_ago=1)
|
|
emitted = await check_stuck_focused_sessions(
|
|
self.store, run_id=self.run_id, threshold_minutes=10,
|
|
)
|
|
self.assertEqual(len(emitted), 0)
|
|
|
|
async def test_unfocused_session_never_emits(self) -> None:
|
|
await self._seed_session(role_id="coo", focused="", minutes_ago=60)
|
|
emitted = await check_stuck_focused_sessions(
|
|
self.store, run_id=self.run_id, threshold_minutes=10,
|
|
)
|
|
self.assertEqual(len(emitted), 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|