Initial commit
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user