Files
OpenOPC/tests/test_phase_transition_hooks.py
2026-07-01 17:56:31 +08:00

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()