Files
OpenOPC/tests/test_lock_free_checkpoint_answer.py
LZH-YS1998 57610e57a6 fix(ui): keep late-approval fast path cross-channel; surface identity errors in chat
PR #27 scoped the lock-free parked-checkpoint answer to exact checkpoint
task/session equality. Company gate cards are raised by role work-item
tasks but answered from the run's anchor chat, whose task id only
appears in payload["task_ids"] — the exact-match guard silently
disabled the fast path for precisely the answers it exists for and
re-opened the project-0012 late-approval lock wedge. Scope by the same
linkage set _find_parked_checkpoint_for_deferred_resume uses (checkpoint
task/session plus payload waiting_task_id/task_ids), keep rejecting
unrelated channels, and keep legacy checkpoints without linkage
deliverable.

The new fail-closed identity errors in _process_session_message raised
out of fire-and-forget background tasks (_track_session), where they are
only logged and the user's message silently vanishes. Surface them as a
visible system chat error and stop instead of raising.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:37:00 +08:00

367 lines
14 KiB
Python

"""Regression tests for the lock-free parked-checkpoint answer path.
Project-0012 forensics: a company goal turn holds the per-task session lock
for hours while its live dispatcher waits on AWAITING_HUMAN approval cards.
The card answers are session messages, so they queued behind that same lock —
a circular wait (dispatcher -> answer -> lock -> dispatcher) that left the
approval clicks undelivered forever. The fix routes a reply that explicitly
targets a pending park checkpoint through the engine's checkpoint-resume
channel without acquiring the turn lock.
"""
from __future__ import annotations
import asyncio
import unittest
from types import SimpleNamespace
from typing import Any
from opc.plugins.office_ui.ws_handler import WSHandler
class _ChatStoreStub:
def __init__(self) -> None:
self.inserted: list[dict[str, Any]] = []
async def insert_message(self, **kwargs: Any) -> dict[str, Any]:
self.inserted.append(kwargs)
return {"message_id": f"msg-{len(self.inserted)}", **kwargs}
class _StoreStub:
def __init__(self, pending: list[Any]) -> None:
self._pending = pending
async def get_pending_checkpoints(self, project_id: str = "default") -> list[Any]:
return list(self._pending)
class _EngineStub:
def __init__(self, store: Any, *, reply: str = "Input received.", error: Exception | None = None) -> None:
self.store = store
self.reply = reply
self.error = error
self.calls: list[dict[str, Any]] = []
async def process_message(self, content: str, **kwargs: Any) -> str:
self.calls.append({"content": content, **kwargs})
if self.error is not None:
raise self.error
return self.reply
def _pending_checkpoint(
checkpoint_id: str,
checkpoint_type: str = "task_user_input",
*,
task_id: str = "chat-task",
session_id: str = "session-1",
) -> Any:
return SimpleNamespace(
checkpoint_id=checkpoint_id,
checkpoint_type=checkpoint_type,
status="pending",
task_id=task_id,
session_id=session_id,
payload={"task_ids": [task_id], "waiting_task_id": task_id, "session_id": session_id},
)
def _make_handler(engine: _EngineStub) -> WSHandler:
handler = object.__new__(WSHandler)
handler._task_locks = {}
handler._task_lock_holders = {}
handler.chat_store = _ChatStoreStub()
handler._store_is_ready = lambda store: store is not None
handler.broadcast = _async_noop
handler._mark_checkpoint_card_after_engine_response = _async_none_kwargs
return handler
async def _async_noop(*args: Any, **kwargs: Any) -> None:
return None
async def _async_none_kwargs(**kwargs: Any) -> None:
return None
def _answer_kwargs(**overrides: Any) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"task_id": "chat-task",
"content": "Approval decision: approve_session. Re-run it and continue the task.",
"session_id": "session-1",
"message_metadata": {
"response_to_checkpoint_id": "ckpt-park",
"response_to_checkpoint_type": "task_user_input",
},
"user_message_id": "ui-msg-1",
"user_message_created_at": None,
"pid": "0012",
"channel_id": "session:chat-task",
"session_exec_mode": "company",
"session_company_profile": "corporate",
"session_org_id": "",
"attachment_refs": None,
}
kwargs.update(overrides)
return kwargs
class LockFreeCheckpointAnswerTests(unittest.IsolatedAsyncioTestCase):
async def _hold_lock(self, handler: WSHandler, task_id: str) -> asyncio.Task:
lock = handler._get_task_lock(task_id)
acquired = asyncio.Event()
release = asyncio.Event()
async def _holder() -> None:
async with lock:
acquired.set()
await release.wait()
holder = asyncio.create_task(_holder())
await acquired.wait()
handler._task_lock_holders[task_id] = holder
holder.release_event = release # type: ignore[attr-defined]
return holder
async def test_lock_held_delivers_through_resume_channel(self) -> None:
engine = _EngineStub(
_StoreStub([_pending_checkpoint("ckpt-park")]),
reply="Input received. The company runtime is live and will pick it up on its next dispatch tick.",
)
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine, **_answer_kwargs()
)
self.assertTrue(handled)
self.assertEqual(len(engine.calls), 1)
call = engine.calls[0]
self.assertEqual(call["mode"], "company")
self.assertEqual(call["project_id"], "0012")
self.assertEqual(
call["message_metadata"]["response_to_checkpoint_id"], "ckpt-park"
)
# The turn lock must remain untouched — still held by the live turn.
self.assertTrue(handler._get_task_lock("chat-task").locked())
# The engine reply is surfaced to the session channel.
replies = [m for m in handler.chat_store.inserted if m.get("sender") == "assistant"]
self.assertEqual(len(replies), 1)
self.assertIn("Input received", replies[0]["content"])
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_lock_free_session_keeps_serialized_path(self) -> None:
engine = _EngineStub(_StoreStub([_pending_checkpoint("ckpt-park")]))
handler = _make_handler(engine)
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine, **_answer_kwargs()
)
self.assertFalse(handled)
self.assertEqual(engine.calls, [])
async def test_unknown_or_resolved_checkpoint_declines(self) -> None:
engine = _EngineStub(_StoreStub([]))
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine, **_answer_kwargs()
)
self.assertFalse(handled)
self.assertEqual(engine.calls, [])
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_non_park_checkpoint_type_declines(self) -> None:
engine = _EngineStub(
_StoreStub([_pending_checkpoint("ckpt-park", "company_delivery_feedback")])
)
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine,
**_answer_kwargs(
message_metadata={
"response_to_checkpoint_id": "ckpt-park",
"response_to_checkpoint_type": "company_delivery_feedback",
}
),
)
self.assertFalse(handled)
self.assertEqual(engine.calls, [])
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_lock_free_requires_exact_checkpoint_type_and_owner(self) -> None:
engine = _EngineStub(
_StoreStub([
_pending_checkpoint(
"ckpt-park",
"company_work_item_gate",
task_id="other-task",
session_id="other-session",
)
])
)
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine,
**_answer_kwargs(),
)
self.assertFalse(handled)
self.assertEqual(engine.calls, [])
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_engine_failure_surfaces_error_without_queueing(self) -> None:
engine = _EngineStub(
_StoreStub([_pending_checkpoint("ckpt-park")]),
error=RuntimeError("resume blew up"),
)
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine, **_answer_kwargs()
)
# Handled=True: the reply must NOT fall through to the locked path,
# which would silently queue behind the wedged turn again.
self.assertTrue(handled)
errors = [m for m in handler.chat_store.inserted if m.get("sender") == "system"]
self.assertEqual(len(errors), 1)
self.assertIn("resume blew up", errors[0]["content"])
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_stale_done_holder_lock_self_heals_and_declines(self) -> None:
engine = _EngineStub(_StoreStub([_pending_checkpoint("ckpt-park")]))
handler = _make_handler(engine)
lock = handler._get_task_lock("chat-task")
await lock.acquire()
async def _finished() -> None:
return None
done_holder = asyncio.create_task(_finished())
await done_holder
handler._task_lock_holders["chat-task"] = done_holder
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine, **_answer_kwargs()
)
# _get_task_lock replaces the stale lock, so the fresh lock is free and
# the normal serialized path is the right route.
self.assertFalse(handled)
self.assertEqual(engine.calls, [])
async def test_anchor_channel_answer_for_role_task_gate_is_handled(self) -> None:
# Project-0012 production shape: a company gate checkpoint is raised
# by a role work-item task, but the card is answered from the run's
# anchor chat channel. The anchor task id only appears in
# payload["task_ids"]; exact task/session equality would reject it
# and re-open the late-approval lock wedge.
checkpoint = SimpleNamespace(
checkpoint_id="ckpt-park",
checkpoint_type="company_work_item_gate",
status="pending",
task_id="role-task",
session_id="role-session",
payload={
"waiting_task_id": "role-task",
"session_id": "role-session",
"task_ids": ["role-task", "chat-task"],
},
)
engine = _EngineStub(_StoreStub([checkpoint]))
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine,
**_answer_kwargs(
message_metadata={
"response_to_checkpoint_id": "ckpt-park",
"response_to_checkpoint_type": "company_work_item_gate",
},
),
)
self.assertTrue(handled)
self.assertEqual(len(engine.calls), 1)
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_legacy_checkpoint_without_linkage_is_still_handled(self) -> None:
# Checkpoints persisted before ownership fields existed carry no
# task/session linkage at all. They must keep the pre-scoping
# behavior (deliver by explicit checkpoint id) instead of silently
# falling back to the wedged serialized path.
checkpoint = SimpleNamespace(
checkpoint_id="ckpt-park",
checkpoint_type="task_user_input",
status="pending",
payload={},
)
engine = _EngineStub(_StoreStub([checkpoint]))
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine, **_answer_kwargs()
)
self.assertTrue(handled)
self.assertEqual(len(engine.calls), 1)
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
class SessionIdentityErrorSurfacingTests(unittest.IsolatedAsyncioTestCase):
async def test_identity_service_error_surfaces_in_chat_instead_of_raising(self) -> None:
# _process_session_message mostly runs as a fire-and-forget background
# task; a ServiceError escaping it is only logged and the user's
# message silently vanishes. The pre-lock identity resolution must
# surface the failure as a visible chat error and stop.
from opc.plugins.office_ui.services.models import ServiceError
class _Store:
async def get_task(self, task_id: str) -> Any:
return SimpleNamespace(id=task_id, session_id="sess-1", metadata={})
engine = _EngineStub(_Store())
handler = _make_handler(engine)
handler.engine = engine
handler._session_to_task = {}
handler._exec_mode = "company"
handler._company_profile = "corporate"
handler._task_preferred_agent = "native"
async def _raise_identity_error(*args: Any, **kwargs: Any) -> Any:
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime identity could not be resolved",
{"task_id": "chat-task"},
)
handler._resolve_session_runtime_config_task = _raise_identity_error
await handler._process_session_message("chat-task", "please continue")
self.assertEqual(engine.calls, [])
errors = [m for m in handler.chat_store.inserted if m.get("sender") == "system"]
self.assertEqual(len(errors), 1)
self.assertIn("Company runtime identity could not be resolved", errors[0]["content"])
if __name__ == "__main__":
unittest.main()