"""Regression tests for ``opc.layer2_organization.work_item_transition``. Work-item transition adds ``transition_work_item_from_task`` — the task-facing wrapper that replaces direct ``task.status = ...`` writes in company-mode code. These tests cover: * work_item resolution from hydrated task link (or lack thereof → False return) * TaskStatus → Phase projection with BLOCKED disambiguation * Silent preservation of persisted phase when the desired transition would be invalid (late async race — see docstring in the module). * Phase passed directly bypasses TaskStatus projection. * release_claim flag plumbs through to the underlying transition. """ from __future__ import annotations import tempfile import unittest from pathlib import Path from opc.core.models import DelegationWorkItem, Phase, Task, TaskStatus from opc.database.store import OPCStore from opc.layer2_organization import phase_hooks # noqa: F401 (register hooks) from opc.layer2_organization.work_item_links import set_linked_work_item_id from opc.layer2_organization.work_item_runtime import mark_work_item_runtime from opc.layer2_organization.work_item_transition import ( apply_task_status_transition, transition_work_item_from_task, ) def _make_work_item( *, work_item_id: str = "wi-1", run_id: str = "run-1", phase: Phase = Phase.READY, claimed_by: str = "", ) -> DelegationWorkItem: return DelegationWorkItem( work_item_id=work_item_id, run_id=run_id, cell_id="c", team_instance_id="ti", team_id="t", role_id="r", seat_id="s", title=f"item-{work_item_id}", phase=phase, claimed_by_role_runtime_session_id=claimed_by, claimed_by_seat_id="seat::x" if claimed_by else "", metadata={}, ) def _make_task( *, task_id: str = "task-1", work_item_id: str | None = "wi-1", pending_children: list[str] | None = None, status: TaskStatus = TaskStatus.PENDING, ) -> Task: metadata: dict = {} if pending_children is not None: metadata["delegation_pending_work_item_ids"] = list(pending_children) task = Task( id=task_id, title="t", description="", assigned_to="r", status=status, metadata=metadata, ) if work_item_id: set_linked_work_item_id(task, work_item_id) return task class TransitionWorkItemFromTaskTests(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() async def test_returns_false_without_wid_but_syncs_local_status(self) -> None: """Task-mode leakage path: no linked work item → helper returns False BUT still syncs local task.status so callers that subsequently do save_task(task) persist the caller's intent. This obviates the need for each migrated call site to add its own ``if not ok: task.status = ...`` fallback.""" task = _make_task(work_item_id=None, status=TaskStatus.RUNNING) ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.DONE, reason="test-no-wid", ) self.assertFalse(ok) self.assertEqual(task.status, TaskStatus.DONE) async def test_returns_false_without_wid_phase_arg_projects_to_status(self) -> None: """Same as above but caller passes a Phase; local task.status is the projection via task_status_for_phase.""" task = _make_task(work_item_id=None, status=TaskStatus.RUNNING) ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=Phase.APPROVED, reason="test-no-wid-phase", ) self.assertFalse(ok) self.assertEqual(task.status, TaskStatus.DONE) # APPROVED → DONE async def test_require_work_item_does_not_mutate_company_runtime_without_link(self) -> None: task = _make_task(work_item_id=None, status=TaskStatus.RUNNING) task.metadata = mark_work_item_runtime({"runtime_model": "multi_team_org"}) ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.CANCELLED, reason="test-require-work-item", require_work_item=True, ) self.assertFalse(ok) self.assertEqual(task.status, TaskStatus.RUNNING) with self.assertRaisesRegex(RuntimeError, "linked WorkItem"): await apply_task_status_transition( self.store, task, target_status_or_phase=TaskStatus.CANCELLED, reason="test-apply-require-work-item", ) self.assertEqual(task.status, TaskStatus.RUNNING) async def test_apply_plain_task_fallback_persists_status(self) -> None: task = _make_task(work_item_id=None, status=TaskStatus.RUNNING) await self.store.save_task(task) ok = await apply_task_status_transition( self.store, task, target_status_or_phase=TaskStatus.DONE, reason="test-plain-apply", ) self.assertFalse(ok) fresh = await self.store.get_task(task.id) self.assertEqual(fresh.status, TaskStatus.DONE) async def test_apply_linked_company_runtime_statuses_project_through_phase_hook(self) -> None: cases = [ (TaskStatus.PENDING, Phase.RUNNING, Phase.READY), (TaskStatus.RUNNING, Phase.READY, Phase.RUNNING), (TaskStatus.DONE, Phase.AWAITING_MANAGER_REVIEW, Phase.APPROVED), (TaskStatus.FAILED, Phase.READY, Phase.FAILED), (TaskStatus.CANCELLED, Phase.READY, Phase.CANCELLED), ] for target_status, initial_phase, expected_phase in cases: with self.subTest(target_status=target_status.value): wid = f"wi-{target_status.value}" tid = f"task-{target_status.value}" wi = _make_work_item(work_item_id=wid, phase=initial_phase) await self.store.save_delegation_work_item(wi) task = _make_task(task_id=tid, work_item_id=wid, status=TaskStatus.RUNNING) task.metadata = mark_work_item_runtime({"runtime_model": "multi_team_org"}) await self.store.save_task(task) await self.store.link_work_item_runtime_task(wid, tid) ok = await apply_task_status_transition( self.store, task, target_status_or_phase=target_status, reason="test-company-apply", release_claim=target_status == TaskStatus.CANCELLED, ) self.assertTrue(ok) after_item = await self.store.get_delegation_work_item(wid) after_task = await self.store.get_task(tid) self.assertEqual(after_item.phase, expected_phase) self.assertEqual(after_task.status, task.status) async def test_phase_passed_directly(self) -> None: """Caller can pass an explicit Phase; no TaskStatus projection runs.""" wi = _make_work_item(phase=Phase.READY) await self.store.save_delegation_work_item(wi) task = _make_task() ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=Phase.RUNNING, reason="test-phase-direct", ) self.assertTrue(ok) after = await self.store.get_delegation_work_item("wi-1") self.assertEqual(after.phase, Phase.RUNNING) # task_id back-reference stamped self.assertEqual(after.metadata.get("task_id"), task.id) async def test_task_status_projection_running(self) -> None: wi = _make_work_item(phase=Phase.READY) await self.store.save_delegation_work_item(wi) task = _make_task() ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.RUNNING, reason="test-status-running", ) self.assertTrue(ok) after = await self.store.get_delegation_work_item("wi-1") self.assertEqual(after.phase, Phase.RUNNING) async def test_blocked_with_pending_children_routes_to_waiting_for_children(self) -> None: """BLOCKED with pending children → WAITING_FOR_CHILDREN. Preserves the old task-status projection rule. """ wi = _make_work_item(phase=Phase.RUNNING) await self.store.save_delegation_work_item(wi) task = _make_task(pending_children=["child-1", "child-2"]) ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.BLOCKED, reason="test-blocked-children", ) self.assertTrue(ok) after = await self.store.get_delegation_work_item("wi-1") self.assertEqual(after.phase, Phase.WAITING_FOR_CHILDREN) async def test_blocked_without_pending_children_routes_to_paused(self) -> None: """BLOCKED with empty/no pending children → PAUSED.""" wi = _make_work_item(phase=Phase.RUNNING) await self.store.save_delegation_work_item(wi) task = _make_task(pending_children=[]) ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.BLOCKED, reason="test-blocked-no-children", ) self.assertTrue(ok) after = await self.store.get_delegation_work_item("wi-1") self.assertEqual(after.phase, Phase.PAUSED) async def test_preserves_invalid_transition_silently(self) -> None: """Late async race: caller tries a transition that's not in ALLOWED_TRANSITIONS from the persisted phase. Instead of raising (which would crash the caller's work-item task), silently preserve the persisted phase and return True. """ wi = _make_work_item(phase=Phase.APPROVED) await self.store.save_delegation_work_item(wi) task = _make_task() # APPROVED is terminal; APPROVED → RUNNING is not allowed. ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.RUNNING, reason="test-invalid-race", ) self.assertTrue(ok) after = await self.store.get_delegation_work_item("wi-1") # Phase preserved. self.assertEqual(after.phase, Phase.APPROVED) async def test_release_claim_flag_plumbs_through(self) -> None: """release_claim=True clears claimed_by_* atomically with the phase write.""" wi = _make_work_item( phase=Phase.RUNNING, claimed_by="session-abc", ) await self.store.save_delegation_work_item(wi) task = _make_task() ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.FAILED, reason="test-release-claim", release_claim=True, ) self.assertTrue(ok) after = await self.store.get_delegation_work_item("wi-1") self.assertEqual(after.phase, Phase.FAILED) self.assertEqual(after.claimed_by_role_runtime_session_id, "") self.assertEqual(after.claimed_by_seat_id, "") async def test_string_target_parsed_as_phase_first(self) -> None: """Strings that match a Phase name are parsed as Phase (not TaskStatus). ``ready_for_rework`` is a Phase but not a TaskStatus — verify it maps to Phase.READY_FOR_REWORK. Use AWAITING_MANAGER_REVIEW as source since that's the natural reviewer-returns-rework path and is in ALLOWED_TRANSITIONS. """ wi = _make_work_item(phase=Phase.AWAITING_MANAGER_REVIEW) await self.store.save_delegation_work_item(wi) task = _make_task() ok = await transition_work_item_from_task( self.store, task, target_status_or_phase="ready_for_rework", reason="test-string-phase", ) self.assertTrue(ok) after = await self.store.get_delegation_work_item("wi-1") self.assertEqual(after.phase, Phase.READY_FOR_REWORK) async def test_local_task_status_synced_after_transition(self) -> None: """After a successful transition the local Task object's ``status`` attribute reflects the new phase, so a subsequent ``save_task(task)`` by the caller doesn't race with the hook's DB update.""" wi = _make_work_item(phase=Phase.RUNNING) await self.store.save_delegation_work_item(wi) task = _make_task(status=TaskStatus.RUNNING) self.assertEqual(task.status, TaskStatus.RUNNING) await transition_work_item_from_task( self.store, task, target_status_or_phase=Phase.FAILED, reason="test-local-sync", ) self.assertEqual(task.status, TaskStatus.FAILED) async def test_local_task_status_not_synced_on_silent_degrade(self) -> None: """When the persisted phase is preserved (invalid transition), the local task.status must NOT be changed — preserving caller's view of what they attempted vs. what actually happened.""" wi = _make_work_item(phase=Phase.APPROVED) await self.store.save_delegation_work_item(wi) task = _make_task(status=TaskStatus.RUNNING) # APPROVED → RUNNING is invalid; helper degrades silently. await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.RUNNING, reason="test-degrade-no-sync", ) # Local status unchanged (still RUNNING as we set it). self.assertEqual(task.status, TaskStatus.RUNNING) async def test_metadata_updates_merged_with_backref(self) -> None: """Caller's metadata_updates are merged after the task_id/task_status back-reference — caller keys win on conflict.""" wi = _make_work_item(phase=Phase.RUNNING) await self.store.save_delegation_work_item(wi) task = _make_task() ok = await transition_work_item_from_task( self.store, task, target_status_or_phase=TaskStatus.AWAITING_MANAGER_REVIEW, reason="test-metadata-merge", metadata_updates={"review_owner_role_id": "cto", "completion_report": "done"}, ) self.assertTrue(ok) after = await self.store.get_delegation_work_item("wi-1") self.assertEqual(after.phase, Phase.AWAITING_MANAGER_REVIEW) self.assertEqual(after.metadata.get("task_id"), task.id) self.assertEqual(after.metadata.get("review_owner_role_id"), "cto") self.assertEqual(after.metadata.get("completion_report"), "done") if __name__ == "__main__": unittest.main()