"""Fix 5 PR3 tests — per-role serial queue semantics. Covers: - Schema + model roundtrip for ``pending_work_item_ids``. - Atomic enqueue/dequeue helpers (FIFO, idempotent, busy check). - ``enqueue_session_work_on_runnable_hook`` firing only when the feature flag is on AND the session is focused on a different work item. - ``clear_session_focus_on_terminal_hook`` dequeuing the next pending work item and clearing its ``queued_behind_session`` stamp. - ``is_dispatchable`` returns False for items stamped with ``queued_behind_session``. The flag defaults to ON. Compatibility tests can still flip ``store.role_serial_queue_enabled = False`` before acting. """ from __future__ import annotations import tempfile import textwrap import unittest from pathlib import Path from opc.core.config import OPCConfig from opc.core.models import ( DelegationRoleSession, DelegationWorkItem, Phase, SeatState, Task, TaskStatus, ) from opc.database.store import OPCStore from opc.layer2_organization import phase_hooks # noqa: F401 (registers hooks) from opc.layer2_organization.phase import is_dispatchable from opc.layer2_organization.phase_hooks import reconcile_role_serial_queues class _StoreFixture(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() async def asyncTearDown(self) -> None: await self.store.close() self._tmpdir.cleanup() # ── Schema + model roundtrip ───────────────────────────────────────────── class SchemaRoundtripTests(_StoreFixture): async def test_pending_work_item_ids_persists(self) -> None: session = DelegationRoleSession( role_session_id="role-runtime::r::cto", run_id="r", role_id="cto", pending_work_item_ids=["wi-1", "wi-2", "wi-3"], ) await self.store.save_delegation_role_session(session) loaded = await self.store.get_delegation_role_session("role-runtime::r::cto") self.assertIsNotNone(loaded) self.assertEqual(loaded.pending_work_item_ids, ["wi-1", "wi-2", "wi-3"]) async def test_default_pending_is_empty_list(self) -> None: session = DelegationRoleSession( role_session_id="role-runtime::r::cto", run_id="r", role_id="cto", ) await self.store.save_delegation_role_session(session) loaded = await self.store.get_delegation_role_session("role-runtime::r::cto") self.assertEqual(loaded.pending_work_item_ids, []) async def test_legacy_role_statuses_normalize_to_three_states(self) -> None: cases = [ ("cold", "", "idle", ""), ("reserved", "wi-1", "running", "wi-1"), ("running", "", "idle", ""), ("idle", "wi-stale", "idle", ""), ("dead", "", "blocked", ""), ("handoff_pending", "wi-2", "blocked", "wi-2"), ] for index, (status, focus, expected_status, expected_focus) in enumerate(cases): sid = f"role-runtime::r::legacy-{index}" await self.store.save_delegation_role_session( DelegationRoleSession( role_session_id=sid, run_id="r", role_id=f"legacy-{index}", status=status, focused_work_item_id=focus, ) ) loaded = await self.store.get_delegation_role_session(sid) self.assertEqual(loaded.status, expected_status) self.assertEqual(loaded.focused_work_item_id, expected_focus) async def test_seat_status_and_resident_status_normalize_together(self) -> None: seat = SeatState( seat_state_id="seat-state::r::cto", team_instance_id="team-instance::r::cto", run_id="r", team_id="team::cto", seat_id="seat::cto", role_id="cto", status="reserved", resident_status="cold", current_work_item_id="wi-1", ) await self.store.save_seat_state(seat) loaded = await self.store.get_seat_state(seat.seat_state_id) self.assertEqual(loaded.status, "running") self.assertEqual(loaded.resident_status, "running") # ── Atomic enqueue/dequeue + busy check ───────────────────────────────── class QueueHelperTests(_StoreFixture): async def _seed_session(self, *, focused: str = "") -> str: sid = "role-runtime::r::cto" session = DelegationRoleSession( role_session_id=sid, run_id="r", role_id="cto", focused_work_item_id=focused, status="running" if focused else "idle", ) await self.store.save_delegation_role_session(session) return sid async def test_enqueue_appends_in_order(self) -> None: sid = await self._seed_session() self.assertTrue(await self.store.enqueue_pending_work_item(sid, "wi-1")) self.assertTrue(await self.store.enqueue_pending_work_item(sid, "wi-2")) self.assertTrue(await self.store.enqueue_pending_work_item(sid, "wi-3")) loaded = await self.store.get_delegation_role_session(sid) self.assertEqual(loaded.pending_work_item_ids, ["wi-1", "wi-2", "wi-3"]) async def test_enqueue_duplicate_is_noop(self) -> None: sid = await self._seed_session() self.assertTrue(await self.store.enqueue_pending_work_item(sid, "wi-1")) # Second enqueue returns False and leaves the queue intact. self.assertFalse(await self.store.enqueue_pending_work_item(sid, "wi-1")) loaded = await self.store.get_delegation_role_session(sid) self.assertEqual(loaded.pending_work_item_ids, ["wi-1"]) async def test_enqueue_unknown_session_returns_false(self) -> None: self.assertFalse( await self.store.enqueue_pending_work_item("no::such::session", "wi-1") ) async def test_dequeue_pops_fifo_head(self) -> None: sid = await self._seed_session() for wid in ("wi-1", "wi-2", "wi-3"): await self.store.enqueue_pending_work_item(sid, wid) self.assertEqual(await self.store.dequeue_pending_work_item(sid), "wi-1") self.assertEqual(await self.store.dequeue_pending_work_item(sid), "wi-2") loaded = await self.store.get_delegation_role_session(sid) self.assertEqual(loaded.pending_work_item_ids, ["wi-3"]) async def test_dequeue_empty_queue_returns_none(self) -> None: sid = await self._seed_session() self.assertIsNone(await self.store.dequeue_pending_work_item(sid)) async def test_role_session_is_busy_reflects_focus(self) -> None: sid = await self._seed_session(focused="wi-current") self.assertTrue(await self.store.role_session_is_busy(sid)) other = await self._seed_other_session() self.assertFalse(await self.store.role_session_is_busy(other)) async def _seed_other_session(self) -> str: sid = "role-runtime::r::cmo" session = DelegationRoleSession( role_session_id=sid, run_id="r", role_id="cmo", ) await self.store.save_delegation_role_session(session) return sid # ── is_dispatchable honors queued stamp ────────────────────────────────── class IsDispatchableQueueFilterTests(unittest.TestCase): def test_returns_false_when_queued_behind_session_stamp_present(self) -> None: item = DelegationWorkItem( work_item_id="wi-queued", run_id="r", cell_id="c", role_id="cto", seat_id="seat", manager_role_id="", manager_seat_id="", title="t", phase=Phase.READY, metadata={"queued_behind_session": "role-runtime::r::cto"}, ) self.assertFalse(is_dispatchable(item)) def test_returns_true_for_runnable_without_stamp(self) -> None: item = DelegationWorkItem( work_item_id="wi-fresh", run_id="r", cell_id="c", role_id="cto", seat_id="seat", manager_role_id="", manager_seat_id="", title="t", phase=Phase.READY, ) self.assertTrue(is_dispatchable(item)) def test_empty_stamp_is_treated_as_not_queued(self) -> None: item = DelegationWorkItem( work_item_id="wi-empty", run_id="r", cell_id="c", role_id="cto", seat_id="seat", manager_role_id="", manager_seat_id="", title="t", phase=Phase.READY, metadata={"queued_behind_session": ""}, ) self.assertTrue(is_dispatchable(item)) def test_returns_false_when_company_runtime_dispatch_hold_present(self) -> None: item = DelegationWorkItem( work_item_id="wi-suspended", run_id="r", cell_id="c", role_id="cto", seat_id="seat", manager_role_id="", manager_seat_id="", title="t", phase=Phase.RUNNING, metadata={"dispatch_hold": "company_runtime_suspended"}, ) self.assertFalse(is_dispatchable(item)) # ── enqueue_session_work_on_runnable_hook ──────────────────────────────── class EnqueueHookTests(_StoreFixture): async def _seed_session(self, *, focused: str = "") -> str: sid = "role-runtime::r::cto" session = DelegationRoleSession( role_session_id=sid, run_id="r", role_id="cto", focused_work_item_id=focused, status="running" if focused else "idle", ) await self.store.save_delegation_role_session(session) return sid async def _save_runnable_item( self, *, wid: str, sid: str ) -> DelegationWorkItem: item = DelegationWorkItem( work_item_id=wid, run_id="r", cell_id="c", role_id="cto", seat_id="seat", role_runtime_session_id=sid, manager_role_id="ceo", manager_seat_id="seat::ceo", title="t", phase=Phase.READY, ) await self.store.save_delegation_work_item(item) return item async def test_flag_off_is_noop(self) -> None: # Flag defaults to False — enqueueing a runnable work item for a # busy session must NOT touch the session queue or stamp metadata. sid = await self._seed_session(focused="wi-current") self.store.role_serial_queue_enabled = False await self._save_runnable_item(wid="wi-new", sid=sid) session = await self.store.get_delegation_role_session(sid) self.assertEqual(session.pending_work_item_ids, []) item = await self.store.get_delegation_work_item("wi-new") self.assertNotIn("queued_behind_session", item.metadata) async def test_flag_on_busy_session_enqueues(self) -> None: sid = await self._seed_session(focused="wi-current") self.store.role_serial_queue_enabled = True await self._save_runnable_item(wid="wi-new", sid=sid) session = await self.store.get_delegation_role_session(sid) self.assertEqual(session.pending_work_item_ids, ["wi-new"]) item = await self.store.get_delegation_work_item("wi-new") self.assertEqual(item.metadata.get("queued_behind_session"), sid) # Stamped item is no longer dispatchable — the claim filter skips it. self.assertFalse(is_dispatchable(item)) async def test_flag_on_idle_session_does_not_enqueue(self) -> None: # An idle session (no focus) should let the work item proceed # through the normal claim path — not get queued. sid = await self._seed_session(focused="") self.store.role_serial_queue_enabled = True await self._save_runnable_item(wid="wi-new", sid=sid) session = await self.store.get_delegation_role_session(sid) self.assertEqual(session.pending_work_item_ids, []) item = await self.store.get_delegation_work_item("wi-new") self.assertNotIn("queued_behind_session", item.metadata) async def test_same_item_as_focus_not_queued(self) -> None: # If the session's focus IS the work item whose phase is moving # (e.g. a re-READY cycle after rework), we must NOT enqueue # against itself — the session is actively handling it. sid = await self._seed_session(focused="wi-same") self.store.role_serial_queue_enabled = True await self._save_runnable_item(wid="wi-same", sid=sid) session = await self.store.get_delegation_role_session(sid) self.assertEqual(session.pending_work_item_ids, []) # ── clear_session_focus_on_terminal_hook — dequeue path ───────────────── class DequeueOnTerminalTests(_StoreFixture): async def test_flag_on_terminal_dequeues_and_clears_stamp(self) -> None: sid = "role-runtime::r::cto" session = DelegationRoleSession( role_session_id=sid, run_id="r", role_id="cto", focused_work_item_id="wi-active", status="running", pending_work_item_ids=["wi-next", "wi-later"], ) await self.store.save_delegation_role_session(session) self.store.role_serial_queue_enabled = True # Queued item has the stamp from when it was enqueued. queued = DelegationWorkItem( work_item_id="wi-next", run_id="r", cell_id="c", role_id="cto", seat_id="s", manager_role_id="ceo", manager_seat_id="seat::ceo", role_runtime_session_id=sid, title="t", phase=Phase.READY, metadata={"queued_behind_session": sid}, ) await self.store.save_delegation_work_item(queued) later = DelegationWorkItem( work_item_id="wi-later", run_id="r", cell_id="c", role_id="cto", seat_id="s", manager_role_id="ceo", manager_seat_id="seat::ceo", role_runtime_session_id=sid, title="t", phase=Phase.READY, metadata={"queued_behind_session": sid}, ) await self.store.save_delegation_work_item(later) # The active work item transitions to APPROVED — terminal. Seed # a linked task so the task.status sync hook also gets its input. task = Task( id="task-active", title="t", description="", assigned_to="cto", status=TaskStatus.RUNNING, project_id="p", session_id="s", ) await self.store.save_task(task) active = DelegationWorkItem( work_item_id="wi-active", run_id="r", cell_id="c", role_id="cto", seat_id="s", manager_role_id="ceo", manager_seat_id="seat::ceo", role_runtime_session_id=sid, title="t", phase=Phase.RUNNING, ) await self.store.save_delegation_work_item(active) await self.store.link_work_item_runtime_task(active.work_item_id, task.id) await self.store.update_delegation_work_item( "wi-active", phase=Phase.APPROVED ) # Head of queue ("wi-next") has been dequeued; stamp removed. session_after = await self.store.get_delegation_role_session(sid) self.assertEqual(session_after.pending_work_item_ids, ["wi-later"]) queued_after = await self.store.get_delegation_work_item("wi-next") self.assertNotIn("queued_behind_session", queued_after.metadata) self.assertTrue(is_dispatchable(queued_after)) async def test_flag_off_terminal_does_not_dequeue(self) -> None: sid = "role-runtime::r::cto" session = DelegationRoleSession( role_session_id=sid, run_id="r", role_id="cto", focused_work_item_id="wi-active", status="running", pending_work_item_ids=["wi-next"], ) await self.store.save_delegation_role_session(session) self.store.role_serial_queue_enabled = False # explicit active = DelegationWorkItem( work_item_id="wi-active", run_id="r", cell_id="c", role_id="cto", seat_id="s", manager_role_id="ceo", manager_seat_id="seat::ceo", role_runtime_session_id=sid, title="t", phase=Phase.RUNNING, ) await self.store.save_delegation_work_item(active) await self.store.update_delegation_work_item( "wi-active", phase=Phase.APPROVED ) # Queue untouched when flag is off. session_after = await self.store.get_delegation_role_session(sid) self.assertEqual(session_after.pending_work_item_ids, ["wi-next"]) # ── serial queue reconciler ────────────────────────────────────────────── class SerialQueueReconcilerTests(_StoreFixture): async def _seed_session( self, *, sid: str = "role-runtime::r::cto", focused: str = "", pending: list[str] | None = None, status: str | None = None, ) -> str: session = DelegationRoleSession( role_session_id=sid, run_id="r", role_id=sid.rsplit("::", 1)[-1], focused_work_item_id=focused, status=status or ("running" if focused else "idle"), pending_work_item_ids=list(pending or []), ) await self.store.save_delegation_role_session(session) return sid async def _save_item( self, *, wid: str, sid: str, phase: Phase = Phase.READY, marker: bool = False, ) -> DelegationWorkItem: item = DelegationWorkItem( work_item_id=wid, run_id="r", cell_id="c", role_id=sid.rsplit("::", 1)[-1], seat_id="seat", manager_role_id="ceo", manager_seat_id="seat::ceo", role_runtime_session_id=sid, title="t", phase=phase, metadata={"queued_behind_session": sid} if marker else {}, ) await self.store.save_delegation_work_item(item) return item async def test_reconcile_clears_stale_marker_for_idle_empty_queue(self) -> None: self.store.role_serial_queue_enabled = True sid = await self._seed_session() await self._save_item(wid="wi-ready", sid=sid, marker=True) result = await reconcile_role_serial_queues(self.store, "r") self.assertIn("wi-ready", result["cleared_markers"]) item = await self.store.get_delegation_work_item("wi-ready") self.assertNotIn("queued_behind_session", item.metadata) self.assertTrue(is_dispatchable(item)) async def test_reconcile_preserves_valid_marker_for_busy_session(self) -> None: self.store.role_serial_queue_enabled = True sid = await self._seed_session(focused="wi-active", pending=["wi-next"]) await self._save_item(wid="wi-active", sid=sid, phase=Phase.RUNNING) await self._save_item(wid="wi-next", sid=sid, marker=True) result = await reconcile_role_serial_queues(self.store, "r") self.assertEqual(result["cleared_markers"], []) session = await self.store.get_delegation_role_session(sid) self.assertEqual(session.pending_work_item_ids, ["wi-next"]) item = await self.store.get_delegation_work_item("wi-next") self.assertEqual(item.metadata.get("queued_behind_session"), sid) self.assertFalse(is_dispatchable(item)) async def test_reconcile_prunes_dead_entries_and_promotes_one_head(self) -> None: self.store.role_serial_queue_enabled = True sid = "role-runtime::r::cto" await self._save_item(wid="wi-done", sid=sid, phase=Phase.APPROVED, marker=True) await self._save_item(wid="wi-next", sid=sid, marker=True) await self._save_item(wid="wi-later", sid=sid, marker=True) await self._seed_session( sid=sid, pending=["wi-missing", "wi-done", "wi-next", "wi-later"], ) result = await reconcile_role_serial_queues(self.store, "r") self.assertIn("wi-missing", result["pruned_pending_ids"]) self.assertIn("wi-done", result["pruned_pending_ids"]) self.assertIn("wi-next", result["promoted_work_item_ids"]) session = await self.store.get_delegation_role_session(sid) self.assertEqual(session.pending_work_item_ids, ["wi-later"]) promoted = await self.store.get_delegation_work_item("wi-next") later = await self.store.get_delegation_work_item("wi-later") self.assertNotIn("queued_behind_session", promoted.metadata) self.assertEqual(later.metadata.get("queued_behind_session"), sid) second = await reconcile_role_serial_queues(self.store, "r") self.assertNotIn("wi-later", second["promoted_work_item_ids"]) later_after = await self.store.get_delegation_work_item("wi-later") self.assertEqual(later_after.metadata.get("queued_behind_session"), sid) async def test_terminal_transition_reconciles_even_when_focus_already_empty(self) -> None: self.store.role_serial_queue_enabled = True sid = await self._seed_session(pending=["wi-next"]) await self._save_item(wid="wi-next", sid=sid, marker=True) active = await self._save_item(wid="wi-active", sid=sid, phase=Phase.RUNNING) await self.store.update_delegation_work_item( active.work_item_id, phase=Phase.APPROVED, ) session = await self.store.get_delegation_role_session(sid) self.assertEqual(session.pending_work_item_ids, []) next_item = await self.store.get_delegation_work_item("wi-next") self.assertNotIn("queued_behind_session", next_item.metadata) self.assertTrue(is_dispatchable(next_item)) async def test_idempotent_work_item_save_repairs_linked_task_status(self) -> None: task = Task( id="task-drift", title="t", description="", assigned_to="cto", status=TaskStatus.AWAITING_MANAGER_REVIEW, project_id="p", session_id="s", metadata={}, ) await self.store.save_task(task) item = DelegationWorkItem( work_item_id="wi-approved", run_id="r", cell_id="c", role_id="cto", seat_id="seat", manager_role_id="ceo", manager_seat_id="seat::ceo", title="t", phase=Phase.APPROVED, ) await self.store.save_delegation_work_item(item) await self.store.link_work_item_runtime_task(item.work_item_id, task.id) task.status = TaskStatus.AWAITING_MANAGER_REVIEW await self.store.save_task(task) await self.store.save_delegation_work_item(item) repaired = await self.store.get_task(task.id) self.assertEqual(repaired.status, TaskStatus.DONE) # ── Config loader picks up the feature flag ────────────────────────────── class ConfigLoaderFlagTests(unittest.TestCase): """OPCConfig.load must pass ``role_serial_queue_enabled`` through from the org YAML. The engine mirrors this onto the store during init so phase hooks can consult it without an upward dependency.""" def test_flag_on_in_yaml_reaches_org_config(self) -> None: with tempfile.TemporaryDirectory() as tmp: config_dir = Path(tmp) (config_dir / "org_config.yaml").write_text( textwrap.dedent( """\ role_serial_queue_enabled: true """ ), encoding="utf-8", ) config = OPCConfig.load(config_dir=config_dir) self.assertTrue(config.org.role_serial_queue_enabled) def test_flag_default_true_when_key_absent(self) -> None: with tempfile.TemporaryDirectory() as tmp: config_dir = Path(tmp) # An empty org_config.yaml — flag should stay at its default. (config_dir / "org_config.yaml").write_text( "team_runtime:\n default_team_id: ''\n", encoding="utf-8", ) config = OPCConfig.load(config_dir=config_dir) self.assertTrue(config.org.role_serial_queue_enabled) if __name__ == "__main__": unittest.main()