229 lines
9.1 KiB
Python
229 lines
9.1 KiB
Python
"""End-to-end tests for the D2 phase-transition hook mechanism.
|
|
|
|
These tests guarantee that whenever a work-item phase changes, the
|
|
downstream layers (task.status, role_session.status, dispatcher wake
|
|
signal) are kept in sync — preventing the regression that caused the
|
|
new11/app04 deadlock where parent CTO/CMO/COO tasks stayed BLOCKED even
|
|
after all children were APPROVED.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from opc.core.models import (
|
|
DelegationRoleSession,
|
|
DelegationWorkItem,
|
|
Phase,
|
|
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 (
|
|
_PHASE_TRANSITION_HOOKS,
|
|
register_phase_transition_hook,
|
|
)
|
|
|
|
|
|
class PhaseHookFiringTests(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_hooks_fire_on_phase_change(self) -> None:
|
|
"""Every save_delegation_work_item call must fire on_phase_transition
|
|
with the previous + target phase + item."""
|
|
captured: list[tuple] = []
|
|
|
|
async def spy(prev, target, item, *, store):
|
|
captured.append((prev, target, item.work_item_id))
|
|
|
|
register_phase_transition_hook(spy)
|
|
try:
|
|
item = DelegationWorkItem(
|
|
run_id="r", cell_id="c", role_id="w", seat_id="ws",
|
|
manager_role_id="m", manager_seat_id="ms", title="t",
|
|
)
|
|
await self.store.save_delegation_work_item(item) # creation
|
|
await self.store.update_delegation_work_item(item.work_item_id, phase=Phase.RUNNING)
|
|
await self.store.update_delegation_work_item(
|
|
item.work_item_id, phase=Phase.AWAITING_MANAGER_REVIEW
|
|
)
|
|
|
|
self.assertEqual(len(captured), 3)
|
|
self.assertIsNone(captured[0][0])
|
|
self.assertEqual(captured[0][1], Phase.READY)
|
|
self.assertEqual(captured[1], (Phase.READY, Phase.RUNNING, item.work_item_id))
|
|
self.assertEqual(
|
|
captured[2],
|
|
(Phase.RUNNING, Phase.AWAITING_MANAGER_REVIEW, item.work_item_id),
|
|
)
|
|
finally:
|
|
if spy in _PHASE_TRANSITION_HOOKS:
|
|
_PHASE_TRANSITION_HOOKS.remove(spy)
|
|
|
|
async def test_idempotent_write_does_not_propagate(self) -> None:
|
|
"""Writing the same phase twice must not re-fire downstream hooks."""
|
|
fired = 0
|
|
|
|
async def counter(prev, target, item, *, store):
|
|
nonlocal fired
|
|
if prev != target:
|
|
fired += 1
|
|
|
|
register_phase_transition_hook(counter)
|
|
try:
|
|
item = DelegationWorkItem(
|
|
run_id="r", cell_id="c", role_id="w", seat_id="ws",
|
|
manager_role_id="m", manager_seat_id="ms", title="t",
|
|
)
|
|
await self.store.save_delegation_work_item(item)
|
|
await self.store.update_delegation_work_item(item.work_item_id, phase=Phase.RUNNING)
|
|
# Idempotent same-phase write
|
|
await self.store.update_delegation_work_item(item.work_item_id, phase=Phase.RUNNING)
|
|
|
|
self.assertEqual(fired, 2) # None→READY, READY→RUNNING
|
|
finally:
|
|
if counter in _PHASE_TRANSITION_HOOKS:
|
|
_PHASE_TRANSITION_HOOKS.remove(counter)
|
|
|
|
|
|
class TaskStatusSyncHookTests(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_task_status_follows_phase(self) -> None:
|
|
task = Task(
|
|
id="task-1", title="t", description="d",
|
|
assigned_to="worker", status=TaskStatus.PENDING,
|
|
project_id="p", session_id="s",
|
|
)
|
|
await self.store.save_task(task)
|
|
|
|
item = DelegationWorkItem(
|
|
run_id="r", cell_id="c", role_id="worker", seat_id="ws",
|
|
manager_role_id="m", manager_seat_id="ms",
|
|
title="t",
|
|
)
|
|
await self.store.save_delegation_work_item(item)
|
|
await self.store.link_work_item_runtime_task(item.work_item_id, task.id)
|
|
await self.store.update_delegation_work_item(item.work_item_id, phase=Phase.RUNNING)
|
|
task_after = await self.store.get_task("task-1")
|
|
self.assertEqual(task_after.status, TaskStatus.RUNNING)
|
|
|
|
await self.store.update_delegation_work_item(
|
|
item.work_item_id, phase=Phase.AWAITING_MANAGER_REVIEW
|
|
)
|
|
task_after = await self.store.get_task("task-1")
|
|
self.assertEqual(task_after.status, TaskStatus.AWAITING_MANAGER_REVIEW)
|
|
|
|
await self.store.update_delegation_work_item(item.work_item_id, phase=Phase.APPROVED)
|
|
task_after = await self.store.get_task("task-1")
|
|
self.assertEqual(task_after.status, TaskStatus.DONE)
|
|
|
|
|
|
# ParentUnblockHookTests (old wake_parent_on_resume_hook regression)
|
|
# was removed in Phase B. The equivalent invariant — that the wake
|
|
# edge releases the parent claim so is_orphaned/is_dispatchable flips
|
|
# True — is now covered by RefreshDependentsClearClaimTests and
|
|
# RehydrateParkedMemberSessionsTests in tests/test_wake_and_delivery.py.
|
|
|
|
|
|
class DispatcherWakeHookTests(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_dispatcher_wake_fires_on_runnable_transition(self) -> None:
|
|
wake_count = 0
|
|
|
|
def wake_cb():
|
|
nonlocal wake_count
|
|
wake_count += 1
|
|
|
|
phase_hooks.register_dispatcher_wake(wake_cb)
|
|
try:
|
|
item = DelegationWorkItem(
|
|
run_id="r", cell_id="c", role_id="w", seat_id="ws",
|
|
manager_role_id="m", manager_seat_id="ms", title="t",
|
|
)
|
|
await self.store.save_delegation_work_item(item)
|
|
wake_after_create = wake_count
|
|
|
|
await self.store.update_delegation_work_item(item.work_item_id, phase=Phase.RUNNING)
|
|
wake_after_run = wake_count
|
|
|
|
# WAITING_FOR_PEER is in IN_PROGRESS_PHASES — not a wake target
|
|
await self.store.update_delegation_work_item(
|
|
item.work_item_id, phase=Phase.WAITING_FOR_PEER
|
|
)
|
|
wake_after_peer = wake_count
|
|
|
|
await self.store.update_delegation_work_item(item.work_item_id, phase=Phase.RUNNING)
|
|
wake_final = wake_count
|
|
|
|
self.assertGreaterEqual(wake_after_create, 1)
|
|
self.assertGreater(wake_after_run, wake_after_create)
|
|
self.assertEqual(wake_after_peer, wake_after_run)
|
|
self.assertGreater(wake_final, wake_after_peer)
|
|
finally:
|
|
phase_hooks.unregister_dispatcher_wake(wake_cb)
|
|
|
|
|
|
class HookRegistrationInvariantTests(unittest.TestCase):
|
|
def test_default_hooks_registered_at_import(self) -> None:
|
|
# Importing company_mode side-imports phase_hooks
|
|
from opc.layer2_organization import company_mode # noqa: F401
|
|
names = {h.__name__ for h in _PHASE_TRANSITION_HOOKS}
|
|
# Phase B reduced the required hooks to task.status projection +
|
|
# dispatcher wake. The wake / reconcile / reenqueue hooks have
|
|
# been replaced by the dispatcher's per-tick rehydrate pass.
|
|
required = {
|
|
"sync_task_status_hook",
|
|
"signal_dispatcher_hook",
|
|
# Fix 3: parent dep-frontier refresh fires on terminal child
|
|
# transitions so stuck leaders wake (new16/app12 regression).
|
|
"refresh_dependents_hook",
|
|
# Fix 6: clear stale focus/status on role sessions when their
|
|
# work item terminates (new16/app13 regression).
|
|
"clear_session_focus_on_terminal_hook",
|
|
# Fix 5 PR3: enqueue runnable work for busy sessions. Gated
|
|
# internally on ``store.role_serial_queue_enabled``.
|
|
"enqueue_session_work_on_runnable_hook",
|
|
}
|
|
self.assertTrue(
|
|
required <= names,
|
|
f"missing required hooks; registered: {names}",
|
|
)
|
|
|
|
def test_register_is_idempotent(self) -> None:
|
|
from opc.layer2_organization.phase_hooks import sync_task_status_hook
|
|
before = sum(1 for h in _PHASE_TRANSITION_HOOKS if h is sync_task_status_hook)
|
|
register_phase_transition_hook(sync_task_status_hook)
|
|
after = sum(1 for h in _PHASE_TRANSITION_HOOKS if h is sync_task_status_hook)
|
|
self.assertEqual(before, after)
|
|
self.assertEqual(after, 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|