test: repair full suite — hang fix, stale-test updates, patch hygiene, timeout backstop

Suite went from 27 failures plus one permanent hang (never finished) to
1846 passed / 0 failed in ~85s, including under FORCE_COLOR.

- office_shutdown_lifecycle: construct WSHandler via the real __init__
  (helper) instead of hand-copied __new__ stubs that drift from the
  constructor (#11 added _runtime_status_sync_task and the stubs hung);
  the formerly-hanging wait now has a 5s wait_for.
- import-time patch hygiene: company_recruiter / company_reorg /
  engine_session_defaults replaced module-level permanent
  tempfile.TemporaryDirectory monkeypatching with paired
  setUpModule/tearDownModule, fixing order-dependent sqlite failures in
  transcript_pagination during full runs.
- stale tests updated to current product semantics: resume stubs use
  status="done" (failed is deliberately non-resumable), fix4 asserts the
  native review contract through build_company_work_item_contract,
  delivery fixture carries user_visible/feedback_scope=final, ownership
  doc names progress_log, session compression calls
  maybe_compact_session(force=True) explicitly, hard delete removes the
  work item row, parallel-isolation asserts delegate rebind and stubs
  _get_project_delegate, role update goes through OrgService on an
  editable custom org (plus read-only rejection case), collab_rpc patches
  the single os.name decision point instead of poisoning pathlib, codex
  no-pty builds inside the patch, identity-guard false positives reworded.
- cli_board actions rewritten against the real OfficeServiceFactory seam
  with a tempdir OPC_HOME (old direct-engine stubs were never consulted
  and the tests wrote into the real OPC home).
- cli_app assertions strip ANSI via _plain_output so a color-forcing
  shell (FORCE_COLOR) cannot break plain-text expectations.
- deleted never-runnable test_org_concurrency (pytest.mark.asyncio
  without the plugin, stdlib-only assertions) and three dead skipped
  filesystem-handoff tests.
- pyproject: dev extra (pytest, pytest-timeout) and a 300s per-test
  timeout backstop so a wedged test fails instead of stalling the suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-26 22:14:47 +08:00
parent 9977c3be57
commit 76c530a9e5
20 changed files with 424 additions and 447 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ The executable owner matrix lives in `opc/layer2_organization/metadata_ownership
| Owner | Examples | Rule | | Owner | Examples | Rule |
|---|---|---| |---|---|---|
| `work_item` | `work_kind`, `current_turn_mode`, dependency/waiting ids, handoff/context preview, prompt contracts, progress logs, role and employee context, review/report metadata, verification fields, delivery package, self-evolution fields | Scheduling, board projection, collaboration, review/report, and user-visible progress reads should use `DelegationWorkItem.metadata`. | | `work_item` | `work_kind`, `current_turn_mode`, dependency/waiting ids, handoff/context preview, prompt contracts, `progress_log`, role and employee context, review/report metadata, verification fields, delivery package, self-evolution fields | Scheduling, board projection, collaboration, review/report, and user-visible progress reads should use `DelegationWorkItem.metadata`. |
| `runtime_task` | `runtime_v2`, `runtime_verification*`, `member_session_state`, `external_resume_*`, `working_memory`, `interrupted_recovery`, `last_stop_reason`, `peer_wait`, comms reactivation audit, `runtime_control_state`, runtime session team/seat ids | Execution infrastructure should store these only on runtime `Task.metadata`. They must not become WorkItem business facts. | | `runtime_task` | `runtime_v2`, `runtime_verification*`, `member_session_state`, `external_resume_*`, `working_memory`, `interrupted_recovery`, `last_stop_reason`, `peer_wait`, comms reactivation audit, `runtime_control_state`, runtime session team/seat ids | Execution infrastructure should store these only on runtime `Task.metadata`. They must not become WorkItem business facts. |
| `execution_copy` | `mode`, `execution_mode`, `runtime_model`, `company_profile`, organization/runtime topology, delegation ids, seat/role routing, execution-agent selection, workspace/comms roots, parent session id | Runtime `Task` may carry these as immutable routing and UI envelope copies. They are not the authoritative business facts. | | `execution_copy` | `mode`, `execution_mode`, `runtime_model`, `company_profile`, organization/runtime topology, delegation ids, seat/role routing, execution-agent selection, workspace/comms roots, parent session id | Runtime `Task` may carry these as immutable routing and UI envelope copies. They are not the authoritative business facts. |
+10
View File
@@ -73,6 +73,11 @@ all = [
"matrix-nio>=0.24.0", "matrix-nio>=0.24.0",
] ]
dev = [
"pytest>=9.0",
"pytest-timeout>=2.3",
]
[project.scripts] [project.scripts]
opc = "opc.cli.app:main" opc = "opc.cli.app:main"
@@ -93,3 +98,8 @@ include = [
"README.md", "README.md",
"pyproject.toml", "pyproject.toml",
] ]
[tool.pytest.ini_options]
# Backstop against wedged tests: a hung test fails after 5 minutes instead
# of stalling the whole suite (requires pytest-timeout, in the `dev` extra).
timeout = 300
+169 -7
View File
@@ -1,6 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os
import tempfile
import unittest import unittest
import uuid
from unittest import mock
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from opc.core.models import ExecutionCheckpoint, Task, TaskStatus from opc.core.models import ExecutionCheckpoint, Task, TaskStatus
@@ -53,17 +58,154 @@ class _StubFacade:
return self._engine return self._engine
class _StubServiceResult:
def __init__(self, payload: dict) -> None:
self.payload = payload
self.events: list = []
class _StubSessionService:
"""In-memory stand-in for the office SessionService seam.
Mirrors the slice of the production contract that ``BoardActions``
relies on: ``create`` persists a session-backed placeholder Task and
ensures its memory session, ``send`` routes execution to
``engine.process_message`` with the origin task's session, and ``stop``
cancels the target task row. Every call is recorded for assertions.
"""
def __init__(self, engine: _StubEngine, project_id: str) -> None:
self._engine = engine
self._project_id = project_id
self.create_calls: list[dict] = []
self.send_calls: list[dict] = []
self.stop_calls: list[dict] = []
async def create(self, **kwargs) -> _StubServiceResult:
self.create_calls.append(dict(kwargs))
project_id = kwargs.get("project_id", self._project_id)
title = str(kwargs.get("title", "") or "New Chat")
task_id = str(uuid.uuid4())
session_id = str(uuid.uuid4())
await self._engine.memory.ensure_session(
session_id=session_id,
project_id=project_id,
title=title,
mode="primary",
metadata={"interface": kwargs.get("interface", "office_ui")},
)
await self._engine.store.save_task(
Task(
id=task_id,
title=title,
description=str(kwargs.get("description", "") or ""),
project_id=project_id,
session_id=session_id,
metadata={"exec_mode": kwargs.get("exec_mode") or "task"},
)
)
return _StubServiceResult({"project_id": project_id, "task_id": task_id, "session_id": session_id})
async def send(self, **kwargs) -> _StubServiceResult:
self.send_calls.append(dict(kwargs))
task = await self._engine.store.get_task(str(kwargs.get("task_id", "") or ""))
if task is None:
raise ValueError(f"task_not_found: {kwargs.get('task_id')}")
response = await self._engine.process_message(
str(kwargs.get("content", "") or ""),
project_id=kwargs.get("project_id", self._project_id),
session_id=str(task.session_id or ""),
mode=kwargs.get("mode", "task"),
origin_task_id=task.id,
)
return _StubServiceResult(
{
"project_id": kwargs.get("project_id", self._project_id),
"task_id": task.id,
"session_id": str(task.session_id or ""),
"response": response,
}
)
async def stop(self, **kwargs) -> _StubServiceResult:
self.stop_calls.append(dict(kwargs))
task = await self._engine.store.get_task(str(kwargs.get("task_id", "") or ""))
if task is None:
raise ValueError(f"target_not_found: {kwargs.get('task_id')}")
task.status = TaskStatus.CANCELLED
await self._engine.store.save_task(task)
return _StubServiceResult(
{
"project_id": kwargs.get("project_id", self._project_id),
"task_id": task.id,
"status": "cancelled",
}
)
class _StubOfficeServices:
def __init__(self, engine: _StubEngine, project_id: str = "demo") -> None:
self.session = _StubSessionService(engine, project_id)
class BoardActionsTests(unittest.IsolatedAsyncioTestCase): class BoardActionsTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
super().setUp()
# Safety net: even if a code path slipped past the factory patch it
# must never touch the real OPC home.
tmp = tempfile.TemporaryDirectory(prefix="opc-cli-board-actions-test-")
self.addCleanup(tmp.cleanup)
env_patcher = mock.patch.dict(os.environ, {"OPC_HOME": tmp.name})
env_patcher.start()
self.addCleanup(env_patcher.stop)
def _make_actions(self, engine: _StubEngine) -> tuple[BoardActions, _StubOfficeServices]:
"""Patch the OfficeServiceFactory seam so BoardActions uses our stubs.
``BoardActions._run_office_service`` builds a real
``OfficeServiceFactory`` (and with it a real engine + ui_state.db) per
operation; the tests replace that seam with an async context manager
yielding stub services bound to the stub engine.
"""
services = _StubOfficeServices(engine)
class _StubFactory:
def __init__(self, **_kwargs) -> None:
pass
async def __aenter__(self) -> _StubOfficeServices:
return services
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
patcher = mock.patch(
"opc.plugins.cli_board.services.actions.OfficeServiceFactory",
_StubFactory,
)
patcher.start()
self.addCleanup(patcher.stop)
return BoardActions(_StubFacade(engine), project_id="demo"), services
async def test_create_task_creates_session_backed_placeholder(self) -> None: async def test_create_task_creates_session_backed_placeholder(self) -> None:
engine = _StubEngine() engine = _StubEngine()
actions = BoardActions(_StubFacade(engine), project_id="demo") actions, services = self._make_actions(engine)
task = await actions.create_task(title="Draft feature", description="Initial plan") task = await actions.create_task(title="Draft feature", description="Initial plan")
self.assertIn(task.id, engine.store.tasks) self.assertIn(task.id, engine.store.tasks)
stored = engine.store.tasks[task.id]
self.assertEqual(stored.title, "Draft feature")
self.assertEqual(stored.description, "Initial plan")
self.assertTrue(stored.session_id)
engine.memory.ensure_session.assert_awaited() engine.memory.ensure_session.assert_awaited()
self.assertEqual(engine.store.tasks[task.id].title, "Draft feature") self.assertEqual(len(services.session.create_calls), 1)
self.assertEqual(engine.store.tasks[task.id].metadata["source"], "cli_board") call = services.session.create_calls[0]
self.assertEqual(call["project_id"], "demo")
self.assertEqual(call["title"], "Draft feature")
self.assertEqual(call["description"], "Initial plan")
self.assertEqual(call["exec_mode"], "task")
self.assertEqual(call["interface"], "cli_board")
async def test_send_session_message_routes_through_origin_task(self) -> None: async def test_send_session_message_routes_through_origin_task(self) -> None:
engine = _StubEngine() engine = _StubEngine()
@@ -76,19 +218,33 @@ class BoardActionsTests(unittest.IsolatedAsyncioTestCase):
project_id="demo", project_id="demo",
) )
await engine.store.save_task(task) await engine.store.save_task(task)
actions = BoardActions(_StubFacade(engine), project_id="demo") actions, services = self._make_actions(engine)
response = await actions.send_session_message("task-1", "please continue") response = await actions.send_session_message("task-1", "please continue")
self.assertEqual(response, "ok") self.assertEqual(response, "ok")
self.assertEqual(len(services.session.send_calls), 1)
call = services.session.send_calls[0]
self.assertEqual(call["project_id"], "demo")
self.assertEqual(call["task_id"], "task-1")
self.assertEqual(call["content"], "please continue")
self.assertEqual(call["mode"], "task")
engine.process_message.assert_awaited_once() engine.process_message.assert_awaited_once()
kwargs = engine.process_message.await_args.kwargs kwargs = engine.process_message.await_args.kwargs
self.assertEqual(kwargs["session_id"], "session-1") self.assertEqual(kwargs["session_id"], "session-1")
self.assertEqual(kwargs["origin_task_id"], "task-1") self.assertEqual(kwargs["origin_task_id"], "task-1")
self.assertEqual(engine.store.tasks["task-1"].status, TaskStatus.IDLE)
async def test_cancel_task_marks_related_tasks_and_checkpoints_cancelled(self) -> None: async def test_cancel_task_marks_related_tasks_and_checkpoints_cancelled(self) -> None:
engine = _StubEngine() engine = _StubEngine()
started = asyncio.Event()
release = asyncio.Event() # never set; the run only ends via cancellation
async def _blocked_process_message(*_args, **_kwargs):
started.set()
await release.wait()
return "unreachable"
engine.process_message = AsyncMock(side_effect=_blocked_process_message)
root = Task( root = Task(
id="root", id="root",
title="Root task", title="Root task",
@@ -116,11 +272,17 @@ class BoardActionsTests(unittest.IsolatedAsyncioTestCase):
task_id="root", task_id="root",
) )
) )
actions = BoardActions(_StubFacade(engine), project_id="demo") actions, services = self._make_actions(engine)
background = asyncio.create_task(actions.send_session_message("root", "keep going"))
await asyncio.wait_for(started.wait(), timeout=5)
await actions.cancel_task("root") await actions.cancel_task("root")
with self.assertRaises(asyncio.CancelledError):
await background
self.assertEqual(engine.store.tasks["root"].status, TaskStatus.CANCELLED) self.assertEqual(engine.store.tasks["root"].status, TaskStatus.CANCELLED)
self.assertEqual(engine.store.tasks["child"].status, TaskStatus.CANCELLED) self.assertEqual(engine.store.tasks["child"].status, TaskStatus.CANCELLED)
self.assertEqual(engine.store.resolved, [("cp-root", "cancelled")]) self.assertEqual(engine.store.resolved, [("cp-root", "cancelled")])
self.assertEqual(len(services.session.stop_calls), 1)
self.assertEqual(services.session.stop_calls[0]["task_id"], "root")
+5 -1
View File
@@ -87,8 +87,12 @@ class CliBoardAppPilotTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause() await pilot.pause()
self.assertEqual(app.state.pane_focus, "context") self.assertEqual(app.state.pane_focus, "context")
await pilot.press("right") # Selecting the running task earlier auto-focused the Session
# context tab (CliBoardApp._load_selected_detail), so cycling
# right moves session -> activity.
self.assertEqual(app.state.context_tab, "session") self.assertEqual(app.state.context_tab, "session")
await pilot.press("right")
self.assertEqual(app.state.context_tab, "activity")
await pilot.press("f") await pilot.press("f")
self.assertFalse(app.state.show_done) self.assertFalse(app.state.show_done)
+21 -8
View File
@@ -4,6 +4,7 @@ import asyncio
import importlib import importlib
import json import json
import os import os
import re
import sqlite3 import sqlite3
import tempfile import tempfile
import unittest import unittest
@@ -58,6 +59,17 @@ from opc.plugins.office_ui.services.project import ProjectService
from opc.plugins.office_ui.services.session import SessionService from opc.plugins.office_ui.services.session import SessionService
from opc.plugins.office_ui.services.work_item import WorkItemService from opc.plugins.office_ui.services.work_item import WorkItemService
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
def _plain_output(text: str) -> str:
"""CLI output with ANSI styling stripped.
Keeps plain-text assertions stable when the invoking shell forces
color (e.g. FORCE_COLOR) onto rich/typer output.
"""
return _ANSI_ESCAPE_RE.sub("", text)
class CliEscalationFormattingTests(unittest.TestCase): class CliEscalationFormattingTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -305,7 +317,7 @@ class CliInitProjectTests(unittest.TestCase):
result = runner.invoke(app, ["init", "dupe"]) result = runner.invoke(app, ["init", "dupe"])
self.assertNotEqual(result.exit_code, 0) self.assertNotEqual(result.exit_code, 0)
self.assertIn("Project 'dupe' already exists", result.output) self.assertIn("Project 'dupe' already exists", _plain_output(result.output))
self.assertFalse((opc_home / "projects" / "dupe").exists()) self.assertFalse((opc_home / "projects" / "dupe").exists())
def test_init_existing_config_cancel_preserves_config_and_does_not_create_project(self) -> None: def test_init_existing_config_cancel_preserves_config_and_does_not_create_project(self) -> None:
@@ -3119,7 +3131,7 @@ class CliChannelCommandTests(unittest.TestCase):
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
mock_kill.assert_called_once() mock_kill.assert_called_once()
self.assertIn("PID 4321", result.stdout) self.assertIn("PID 4321", _plain_output(result.stdout))
def test_channels_status_reports_runtime_and_capabilities(self) -> None: def test_channels_status_reports_runtime_and_capabilities(self) -> None:
config = OPCConfig() config = OPCConfig()
@@ -3165,9 +3177,10 @@ class CliAutomationCommandTests(unittest.TestCase):
result = self.runner.invoke(app, ["exec", "--help"]) result = self.runner.invoke(app, ["exec", "--help"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
self.assertIn("--session-id", result.output) help_text = _plain_output(result.output)
self.assertIn("--resume", result.output) self.assertIn("--session-id", help_text)
self.assertIn("--stream-json", result.output) self.assertIn("--resume", help_text)
self.assertIn("--stream-json", help_text)
def test_exec_rejects_json_and_stream_json_together(self) -> None: def test_exec_rejects_json_and_stream_json_together(self) -> None:
result = self.runner.invoke(app, ["exec", "hello", "--json", "--stream-json"]) result = self.runner.invoke(app, ["exec", "hello", "--json", "--stream-json"])
@@ -3207,9 +3220,9 @@ class CliTalentCommandTests(unittest.TestCase):
self.assertEqual(import_result.exit_code, 0) self.assertEqual(import_result.exit_code, 0)
self.assertEqual(hire_result.exit_code, 0) self.assertEqual(hire_result.exit_code, 0)
self.assertEqual(list_result.exit_code, 0) self.assertEqual(list_result.exit_code, 0)
self.assertIn("Imported 1 talent templates", import_result.stdout) self.assertIn("Imported 1 talent templates", _plain_output(import_result.stdout))
self.assertIn("Bea Backend", hire_result.stdout) self.assertIn("Bea Backend", _plain_output(hire_result.stdout))
self.assertIn("engineering-backend-architect", list_result.stdout) self.assertIn("engineering-backend-architect", _plain_output(list_result.stdout))
config = OPCConfig.load(opc_home / "config") config = OPCConfig.load(opc_home / "config")
self.assertEqual(config.org.talent_templates, []) self.assertEqual(config.org.talent_templates, [])
+12 -1
View File
@@ -131,11 +131,22 @@ class CollaborationRpcTransportTests(unittest.IsolatedAsyncioTestCase):
) )
parser = cli_collab._build_parser() parser = cli_collab._build_parser()
opts = parser.parse_args(["delegate_work", "--args-json-file", str(args_path)]) opts = parser.parse_args(["delegate_work", "--args-json-file", str(args_path)])
with patch.object(cli_collab.os, "name", "nt"), patch.dict( # Simulate os.name == "nt" only at the product's single decision
# point instead of patching the global os.name, which would turn
# every pathlib.Path in this process into a WindowsPath and break
# the POSIX file reads the test itself performs. The real
# rpc_env_configured() check still runs against the patched env,
# so the Windows guard branch is exercised end to end.
with patch.object(
cli_collab,
"_windows_external_rpc_env_configured",
lambda: rpc_env_configured(),
), patch.dict(
"os.environ", "os.environ",
server.client_env, server.client_env,
clear=True, clear=True,
): ):
self.assertTrue(cli_collab._windows_external_rpc_env_configured())
tool_args = cli_collab._collect_tool_args(opts) tool_args = cli_collab._collect_tool_args(opts)
payload, is_error = await cli_collab._dispatch(opts.tool, tool_args) payload, is_error = await cli_collab._dispatch(opts.tool, tool_args)
finally: finally:
+2 -192
View File
@@ -1431,7 +1431,7 @@ class CompanyCollaborationTests(unittest.IsolatedAsyncioTestCase):
task_id="task-1", task_id="task-1",
workspace_path="/tmp/work", workspace_path="/tmp/work",
run_mode="interactive", run_mode="interactive",
status="failed", status="done",
metadata={"resume_session_id": "resume-token-1"}, metadata={"resume_session_id": "resume-token-1"},
updated_at=datetime.now(), updated_at=datetime.now(),
) )
@@ -1466,7 +1466,7 @@ class CompanyCollaborationTests(unittest.IsolatedAsyncioTestCase):
task_id="task-2", task_id="task-2",
workspace_path="/tmp/work", workspace_path="/tmp/work",
run_mode="interactive", run_mode="interactive",
status="failed", status="done",
metadata={}, metadata={},
updated_at=datetime.now(), updated_at=datetime.now(),
) )
@@ -1519,79 +1519,6 @@ class CompanyCollaborationTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("external_resume_session_id", task.metadata) self.assertNotIn("external_resume_session_id", task.metadata)
self.assertNotIn("external_resume_session_scope_id", task.metadata) self.assertNotIn("external_resume_session_scope_id", task.metadata)
@unittest.skip("Filesystem handoff stack removed; see plans/task-cleanup-dead-comms.md"
)
async def test_structured_handoff_is_persisted_and_injected(self) -> None:
with _workspace_tempdir() as tmpdir:
store = OPCStore(Path(tmpdir) / "tasks.db")
await store.initialize()
communication = CommunicationManager(store, EventBus())
memory = DummyMemory()
async def execute_task(task: Task) -> TaskResult:
result = TaskResult(
status=TaskStatus.DONE,
content="Decision: use SQLite\nRisk: guest posting must remain blocked",
artifacts={"workspace": "/tmp/demo", "files": ["src/app.py"]},
)
task.status = result.status
task.result = {"content": result.content, "artifacts": result.artifacts}
return result
executor = CompanyWorkItemExecutor(
org_engine=DummyOrgEngine(),
communication=communication,
approval_engine=SimpleNamespace(),
memory=memory,
execute_task=execute_task,
save_task=store.save_task,
)
upstream = Task(
id="planning-task",
title="Planning",
project_id="proj1",
assigned_to="reviewer",
status=TaskStatus.DONE,
result={"content": "Plan approved with clear milestones.", "artifacts": {}},
metadata={
"work_item_projection_id": "planning",
"work_item_summary_for_downstream": "Plan approved",
"decisions": ["Use SQLite for local persistence"],
"risks": ["Guest posting must remain blocked"],
"artifacts": ["doc: docs/plan.md"],
"acceptance_criteria": ["Implementation follows approved milestones"],
},
)
downstream = Task(
id="execution-task",
title="Execution",
project_id="proj1",
assigned_to="executor",
status=TaskStatus.PENDING,
dependencies=["planning"],
metadata={
"work_item_projection_id": "execution",
"work_item_role_id": "executor",
"work_item_execution_strategy": "native",
"work_item_gate": None,
"progress_log": [],
},
)
await store.save_task(upstream)
await store.save_task(downstream)
await executor._run_work_item(downstream, {"planning": upstream, "execution": downstream})
handoffs = await store.get_handoff_records(project_id="proj1", target_projection_id="execution")
self.assertEqual(len(handoffs), 1)
self.assertEqual(handoffs[0].payload["decisions"], ["Use SQLite for local persistence"])
self.assertIn("Objective: Planning", downstream.metadata["handoff_context"])
self.assertIn("Use SQLite for local persistence", downstream.metadata["handoff_context"])
self.assertEqual(downstream.context_snapshot["handoff_payloads"][0]["source_projection_id"], "planning")
self.assertEqual(memory.calls, [])
await store.close()
async def test_company_gate_prefers_structured_review_verdict_and_persists_work_item_state(self) -> None: async def test_company_gate_prefers_structured_review_verdict_and_persists_work_item_state(self) -> None:
memory = DummyMemory() memory = DummyMemory()
@@ -7197,65 +7124,6 @@ class CompanyCollaborationTests(unittest.IsolatedAsyncioTestCase):
) )
await store.close() await store.close()
@unittest.skip("Filesystem handoff stack removed; see plans/task-cleanup-dead-comms.md"
)
async def test_required_handoff_records_are_persisted_as_sent_and_received(self) -> None:
# The agent-facing `ack_handoff` / `review_handoff` tools were
# deleted; the underlying handoff record is still created and
# transitions sent → received on inbox read. The former
# acked/accepted transitions used to require tool calls and are
# now obsolete.
with _workspace_tempdir() as tmpdir:
store = OPCStore(Path(tmpdir) / "tasks.db")
await store.initialize()
communication = CommunicationManager(store, EventBus())
target_task = Task(
id="review-task",
title="Review",
project_id="proj1",
assigned_to="reviewer",
metadata={
"work_item_projection_id": "review",
"execution_mode": "company_mode",
"workspace_root": str(tmpdir),
"output_root": str(Path(tmpdir) / "deliverables"),
"target_output_dir": str(Path(tmpdir) / "deliverables"),
"comms_root": str(Path(tmpdir) / ".opc-comms"),
},
)
await store.save_task(target_task)
message = await communication.send_handoff(
task_id=target_task.id,
from_agent="executor",
to_agent="reviewer",
subject="Execution handoff",
body="Please review the implementation package.",
handoff={
"handoff_id": "handoff-1",
"summary": "Execution package ready",
"source_projection_id": "execution",
"target_projection_id": "review",
},
requires_ack=True,
)
sent = await store.get_handoff_record("handoff-1")
assert sent is not None
self.assertEqual(sent.status, "sent")
self.assertEqual(message.metadata["handoff_id"], "handoff-1")
_ = await communication.read_inbox(
agent_id="reviewer",
task=target_task,
unread_only=True,
limit=10,
mark_read=True,
)
received = await store.get_handoff_record("handoff-1")
assert received is not None
self.assertEqual(received.status, "received")
await store.close()
async def test_send_dm_writes_file_comms_and_read_inbox_projects_from_file(self) -> None: async def test_send_dm_writes_file_comms_and_read_inbox_projects_from_file(self) -> None:
with _workspace_tempdir() as tmpdir: with _workspace_tempdir() as tmpdir:
store = OPCStore(Path(tmpdir) / "tasks.db") store = OPCStore(Path(tmpdir) / "tasks.db")
@@ -8911,64 +8779,6 @@ class CompanyCollaborationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(inbox[0]["subject"], "Cross-functional copy review") self.assertEqual(inbox[0]["subject"], "Cross-functional copy review")
await store.close() await store.close()
@unittest.skip("Filesystem handoff stack removed; see plans/task-cleanup-dead-comms.md"
)
async def test_context_assembler_includes_ownership_contract_and_pending_handoffs(self) -> None:
class _MemoryStub:
async def build_focused_memory_context(self, **_kwargs: object) -> str:
return ""
async def build_memory_context(self, **_kwargs: object) -> str:
return ""
with _workspace_tempdir() as tmpdir:
store = OPCStore(Path(tmpdir) / "tasks.db")
await store.initialize()
communication = CommunicationManager(store, EventBus())
task = Task(
id="execution-work-item",
session_id="sess-owner",
project_id="proj1",
assigned_to="executor",
metadata={
"execution_mode": "company_mode",
"work_item_projection_id": "execution",
"work_item_projection_title": "Engineering Execution",
"work_item_turn_type": "execute",
"member_session_id": "member::proj1::executor::eng-1",
"ownership_contract": {
"summary": "Implement only the assigned API slice.",
"write_scope": str((Path(tmpdir) / "workspace").resolve()),
"expected_artifacts": ["Updated API implementation", "Verification evidence"],
"downstream_consumer": ["reviewer"],
"allowed_collaboration_targets": ["reviewer", "cto"],
},
},
)
await store.save_task(task)
await communication.send_handoff(
task_id=task.id,
from_agent="planner",
to_agent="executor",
subject="Plan handoff",
body="Use the approved API contract.",
handoff={
"handoff_id": "handoff-ctx",
"summary": "Approved API contract",
"source_projection_id": "planning",
"target_projection_id": "execution",
},
requires_ack=True,
)
assembler = ContextAssembler(_MemoryStub(), store=store, communication=communication)
system_context = await assembler.build_system_context(task, role_id="executor")
self.assertIn("## Topology", system_context)
self.assertIn("Write scope:", system_context)
self.assertIn("Pending Handoff Acknowledgements", system_context)
self.assertIn("handoff-ctx", system_context)
await store.close()
async def test_context_assembler_renders_runtime_owned_mailbox_and_manager_board_summary(self) -> None: async def test_context_assembler_renders_runtime_owned_mailbox_and_manager_board_summary(self) -> None:
class _MemoryStub: class _MemoryStub:
async def build_focused_memory_context(self, **_kwargs: object) -> str: async def build_focused_memory_context(self, **_kwargs: object) -> str:
+9 -1
View File
@@ -30,7 +30,15 @@ from opc.layer2_organization.talent_market import TalentMarket
from opc.layer5_memory.memory_manager import MemoryManager from opc.layer5_memory.memory_manager import MemoryManager
from tests._temp_paths import WorkspaceTemporaryDirectory, workspace_path from tests._temp_paths import WorkspaceTemporaryDirectory, workspace_path
tempfile.TemporaryDirectory = WorkspaceTemporaryDirectory # type: ignore[assignment] _REAL_TEMPORARY_DIRECTORY = tempfile.TemporaryDirectory
def setUpModule() -> None:
tempfile.TemporaryDirectory = WorkspaceTemporaryDirectory # type: ignore[assignment]
def tearDownModule() -> None:
tempfile.TemporaryDirectory = _REAL_TEMPORARY_DIRECTORY # type: ignore[assignment]
class DummyRecruiterLLM: class DummyRecruiterLLM:
+9 -1
View File
@@ -20,7 +20,15 @@ from opc.layer2_organization.org_engine import OrgEngine
from opc.layer2_organization.reorg_manager import ReorgManager from opc.layer2_organization.reorg_manager import ReorgManager
from tests._temp_paths import WorkspaceTemporaryDirectory from tests._temp_paths import WorkspaceTemporaryDirectory
tempfile.TemporaryDirectory = WorkspaceTemporaryDirectory # type: ignore[assignment] _REAL_TEMPORARY_DIRECTORY = tempfile.TemporaryDirectory
def setUpModule() -> None:
tempfile.TemporaryDirectory = WorkspaceTemporaryDirectory # type: ignore[assignment]
def tearDownModule() -> None:
tempfile.TemporaryDirectory = _REAL_TEMPORARY_DIRECTORY # type: ignore[assignment]
class CompanyReorgTests(unittest.IsolatedAsyncioTestCase): class CompanyReorgTests(unittest.IsolatedAsyncioTestCase):
+9 -1
View File
@@ -11,7 +11,15 @@ from opc.engine import OPCEngine
from opc.layer5_memory.secretary_policy import SecretaryPolicyManager from opc.layer5_memory.secretary_policy import SecretaryPolicyManager
from tests._temp_paths import WorkspaceTemporaryDirectory, workspace_path from tests._temp_paths import WorkspaceTemporaryDirectory, workspace_path
tempfile.TemporaryDirectory = WorkspaceTemporaryDirectory # type: ignore[assignment] _REAL_TEMPORARY_DIRECTORY = tempfile.TemporaryDirectory
def setUpModule() -> None:
tempfile.TemporaryDirectory = WorkspaceTemporaryDirectory # type: ignore[assignment]
def tearDownModule() -> None:
tempfile.TemporaryDirectory = _REAL_TEMPORARY_DIRECTORY # type: ignore[assignment]
class _StubStore: class _StubStore:
+7 -2
View File
@@ -1506,7 +1506,7 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("## Skill: memory", worker_task.description) self.assertNotIn("## Skill: memory", worker_task.description)
self.assertIn("## Skill: memory", final_task.description) self.assertIn("## Skill: memory", final_task.description)
async def test_engine_stages_uploaded_attachments_for_external_resume_prompt(self) -> None: async def test_engine_provisions_uploaded_attachments_for_external_resume_prompt(self) -> None:
engine = OPCEngine() engine = OPCEngine()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@@ -2800,7 +2800,6 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})() proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})()
task = Task(title="demo", description="demo") task = Task(title="demo", description="demo")
prompt = adapter.build_task_prompt(task) prompt = adapter.build_task_prompt(task)
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
tmpdir = _make_test_dir("codex-no-pty-argv-prompt") tmpdir = _make_test_dir("codex-no-pty-argv-prompt")
try: try:
@@ -2809,6 +2808,12 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
"opc.layer3_agent.adapters.codex_adapter.asyncio.create_subprocess_exec", "opc.layer3_agent.adapters.codex_adapter.asyncio.create_subprocess_exec",
AsyncMock(return_value=proc), AsyncMock(return_value=proc),
) as spawn_mock: ) as spawn_mock:
# Build inside the no-PTY patch: build_interactive_invocation
# records stdin_policy/interactive_input_channel in metadata,
# and on a real PTY-less host build and start observe the same
# platform capability. Building outside the patch would bake in
# this host's PTY support and contradict the patched start.
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
started = await adapter.start_process( started = await adapter.start_process(
cmd, cmd,
tmpdir, tmpdir,
+10 -2
View File
@@ -18,7 +18,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from opc.core.models import DelegationWorkItem, Phase, RoleRuntimeSession from opc.core.models import DelegationWorkItem, Phase, RoleRuntimeSession, Task
from opc.database.store import OPCStore from opc.database.store import OPCStore
from opc.layer2_organization import phase_hooks # noqa: F401 (register hooks) from opc.layer2_organization import phase_hooks # noqa: F401 (register hooks)
from opc.layer3_agent.adapters.base import ExternalAgentAdapter from opc.layer3_agent.adapters.base import ExternalAgentAdapter
@@ -126,7 +126,15 @@ class ReviewPromptSchemaTests(unittest.TestCase):
self.assertNotIn("auto-rejected", text.lower()) self.assertNotIn("auto-rejected", text.lower())
def test_native_agent_prompt_keeps_suggested_schema(self) -> None: def test_native_agent_prompt_keeps_suggested_schema(self) -> None:
text = native_agent._COMPANY_REVIEW_WORK_ITEM_GUIDELINES # The guidelines live in company_runtime_contract; the native agent
# injects them through build_company_work_item_contract, so assert
# through the wiring the native agent actually uses.
task = Task(
id="review-task",
title="Review work item",
metadata={"work_item_turn_type": "review"},
)
text = native_agent.build_company_work_item_contract(task)
self.assertIn("review_verdict", text) self.assertIn("review_verdict", text)
self.assertIn("blocking_issues", text) self.assertIn("blocking_issues", text)
self.assertNotIn("Mandatory verdict schema", text) self.assertNotIn("Mandatory verdict schema", text)
+2
View File
@@ -432,6 +432,8 @@ class MetadataOwnershipMigratorTests(unittest.IsolatedAsyncioTestCase):
"work_item_projection_id": "ceo::deliver::1", "work_item_projection_id": "ceo::deliver::1",
"work_item_turn_type": "deliver", "work_item_turn_type": "deliver",
"authoritative_output": True, "authoritative_output": True,
"user_visible": True,
"feedback_scope": "final",
}, },
) )
set_linked_work_item_id(task, item.work_item_id) set_linked_work_item_id(task, item.work_item_id)
+1 -1
View File
@@ -976,7 +976,7 @@ class NativeRuntimeV2Tests(unittest.IsolatedAsyncioTestCase):
), ),
) )
# Must NOT block on a human — completes so the company workflow advances. # Must NOT block on a human — completes so the company run advances.
self.assertEqual(result.status, TaskStatus.DONE) self.assertEqual(result.status, TaskStatus.DONE)
# The failed verdict is still recorded for audit / downstream review. # The failed verdict is still recorded for audit / downstream review.
self.assertIn("verification", result.artifacts) self.assertIn("verification", result.artifacts)
+61 -75
View File
@@ -9,6 +9,30 @@ from opc.core.models import ExecutionCheckpoint, Task
from opc.plugins.office_ui.ws_handler import WSHandler from opc.plugins.office_ui.ws_handler import WSHandler
def _make_handler(root_engine: SimpleNamespace | None = None, **overrides: object) -> WSHandler:
"""Build a real WSHandler over stub dependencies.
Constructed through ``__init__`` (rather than ``__new__`` plus a
hand-copied attribute list) so every attribute ``shutdown()`` touches is
initialized by the constructor itself; each test only overrides what its
scenario controls. ``__init__`` is side-effect free for stub inputs: it
assigns state, builds Dispatcher/OfficeServices holders, and wires engine
callbacks via defensive setattr/getattr.
"""
engine = root_engine if root_engine is not None else SimpleNamespace()
if not hasattr(engine, "project_id"):
engine.project_id = "default"
handler = WSHandler(
engine=engine,
agent_store=SimpleNamespace(),
chat_store=None,
event_adapter=SimpleNamespace(),
)
for name, value in overrides.items():
setattr(handler, name, value)
return handler
def test_ws_shutdown_checkpoints_before_cancelling_and_awaiting_sessions() -> None: def test_ws_shutdown_checkpoints_before_cancelling_and_awaiting_sessions() -> None:
async def scenario() -> None: async def scenario() -> None:
events: list[str] = [] events: list[str] = []
@@ -29,18 +53,12 @@ def test_ws_shutdown_checkpoints_before_cancelling_and_awaiting_sessions() -> No
execution_task = asyncio.create_task(execution()) execution_task = asyncio.create_task(execution())
await started.wait() await started.wait()
handler = WSHandler.__new__(WSHandler) handler = _make_handler(
handler.engine = SimpleNamespace() SimpleNamespace(prepare_active_company_runtimes_for_shutdown=prepare),
handler._root_engine = SimpleNamespace( _background_tasks={execution_task},
prepare_active_company_runtimes_for_shutdown=prepare, _task_bg_context={execution_task: {"task_id": "runtime-task"}},
_task_bg_map={"runtime-task": {execution_task}},
) )
handler._shutting_down = False
handler._progress_flush_task = None
handler._clients = set()
handler._active_message_tasks = set()
handler._background_tasks = {execution_task}
handler._task_bg_context = {execution_task: {"task_id": "runtime-task"}}
handler._task_bg_map = {"runtime-task": {execution_task}}
await handler.shutdown(timeout=1.0) await handler.shutdown(timeout=1.0)
@@ -63,20 +81,16 @@ def test_ws_shutdown_checkpoint_failure_does_not_cancel_execution_or_close_the_g
execution_task = asyncio.create_task(execution()) execution_task = asyncio.create_task(execution())
await asyncio.sleep(0) await asyncio.sleep(0)
handler = WSHandler.__new__(WSHandler) handler = _make_handler(
handler.engine = SimpleNamespace() SimpleNamespace(
handler._root_engine = SimpleNamespace( prepare_active_company_runtimes_for_shutdown=AsyncMock(
prepare_active_company_runtimes_for_shutdown=AsyncMock( side_effect=RuntimeError("checkpoint unavailable")
side_effect=RuntimeError("checkpoint unavailable") ),
), ),
_background_tasks={execution_task},
_task_bg_context={execution_task: {"task_id": "runtime-task"}},
_task_bg_map={"runtime-task": {execution_task}},
) )
handler._shutting_down = False
handler._progress_flush_task = None
handler._clients = set()
handler._active_message_tasks = set()
handler._background_tasks = {execution_task}
handler._task_bg_context = {execution_task: {"task_id": "runtime-task"}}
handler._task_bg_map = {"runtime-task": {execution_task}}
try: try:
await handler.shutdown(timeout=1.0) await handler.shutdown(timeout=1.0)
@@ -101,9 +115,7 @@ def test_ws_shutdown_rejects_background_work_scheduled_by_late_ingress() -> None
nonlocal entered nonlocal entered
entered = True entered = True
handler = WSHandler.__new__(WSHandler) handler = _make_handler(_shutting_down=True)
handler._shutting_down = True
handler._background_tasks = set()
task = handler._track(late_work()) task = handler._track(late_work())
await asyncio.gather(task, return_exceptions=True) await asyncio.gather(task, return_exceptions=True)
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -133,17 +145,7 @@ def test_ws_shutdown_drains_queued_duplicate_handoff_before_checkpointing() -> N
_active_task_run_registry=registry, _active_task_run_registry=registry,
prepare_active_company_runtimes_for_shutdown=prepare, prepare_active_company_runtimes_for_shutdown=prepare,
) )
handler = WSHandler.__new__(WSHandler) handler = _make_handler(root_engine)
handler.engine = root_engine
handler._root_engine = root_engine
handler._shutting_down = False
handler._progress_flush_task = None
handler._clients = set()
handler._active_message_tasks = set()
handler._background_tasks = set()
handler._task_bg_context = {}
handler._task_bg_map = {}
handler._handoff_route_tasks = {}
async def execution() -> None: async def execution() -> None:
async with runtime_lock: async with runtime_lock:
@@ -213,20 +215,16 @@ def test_ws_shutdown_fails_closed_while_execution_cleanup_is_still_running() ->
execution_task = asyncio.create_task(execution()) execution_task = asyncio.create_task(execution())
await asyncio.sleep(0) await asyncio.sleep(0)
handler = WSHandler.__new__(WSHandler) handler = _make_handler(
handler.engine = SimpleNamespace() SimpleNamespace(
handler._root_engine = SimpleNamespace( prepare_active_company_runtimes_for_shutdown=AsyncMock(return_value=[]),
prepare_active_company_runtimes_for_shutdown=AsyncMock(return_value=[]), ),
_background_tasks={execution_task},
_task_bg_context={
execution_task: {"task_id": "runtime-task", "execution_handoff": True}
},
_task_bg_map={"runtime-task": {execution_task}},
) )
handler._shutting_down = False
handler._progress_flush_task = None
handler._clients = set()
handler._active_message_tasks = set()
handler._background_tasks = {execution_task}
handler._task_bg_context = {
execution_task: {"task_id": "runtime-task", "execution_handoff": True}
}
handler._task_bg_map = {"runtime-task": {execution_task}}
try: try:
await handler.shutdown(timeout=0.01) await handler.shutdown(timeout=0.01)
@@ -263,21 +261,21 @@ def test_ws_shutdown_cancels_execution_before_waiting_for_client_close() -> None
execution_task = asyncio.create_task(execution()) execution_task = asyncio.create_task(execution())
await asyncio.sleep(0) await asyncio.sleep(0)
client = BlockingWebSocket() client = BlockingWebSocket()
handler = WSHandler.__new__(WSHandler) handler = _make_handler(
handler.engine = SimpleNamespace() SimpleNamespace(
handler._root_engine = SimpleNamespace( prepare_active_company_runtimes_for_shutdown=AsyncMock(return_value=[]),
prepare_active_company_runtimes_for_shutdown=AsyncMock(return_value=[]), ),
_clients={client},
_background_tasks={execution_task},
_task_bg_context={execution_task: {"task_id": "runtime-task"}},
_task_bg_map={"runtime-task": {execution_task}},
) )
handler._shutting_down = False
handler._progress_flush_task = None
handler._clients = {client}
handler._active_message_tasks = set()
handler._background_tasks = {execution_task}
handler._task_bg_context = {execution_task: {"task_id": "runtime-task"}}
handler._task_bg_map = {"runtime-task": {execution_task}}
shutdown_task = asyncio.create_task(handler.shutdown(timeout=1.0)) shutdown_task = asyncio.create_task(handler.shutdown(timeout=1.0))
await close_entered.wait() # close_entered only fires from inside shutdown's client-close loop.
# Bound the wait so a shutdown crash before that loop fails the test
# instead of wedging the whole suite.
await asyncio.wait_for(close_entered.wait(), timeout=5.0)
assert execution_released.is_set() assert execution_released.is_set()
assert execution_task.done() assert execution_task.done()
@@ -302,19 +300,7 @@ def test_duplicate_resume_does_not_leave_shutdown_handoff_barrier_queued() -> No
_active_task_run_registry=registry, _active_task_run_registry=registry,
prepare_active_company_runtimes_for_shutdown=prepare, prepare_active_company_runtimes_for_shutdown=prepare,
) )
handler = WSHandler.__new__(WSHandler) handler = _make_handler(root_engine)
handler.engine = root_engine
handler._root_engine = root_engine
handler.chat_store = None
handler._shutting_down = False
handler._progress_flush_task = None
handler._clients = set()
handler._active_message_tasks = set()
handler._background_tasks = set()
handler._task_bg_context = {}
handler._task_bg_map = {}
handler._company_stop_finalize_tasks = {}
handler._company_suspend_reply_locks = {}
checkpoint = ExecutionCheckpoint( checkpoint = ExecutionCheckpoint(
checkpoint_id="checkpoint-1", checkpoint_id="checkpoint-1",
-106
View File
@@ -1,106 +0,0 @@
"""Concurrency tests for the _config_lock pattern used in P2.
The real WSHandler requires a full engine/store/ws stack to boot, which makes
a pure-unit concurrency test prohibitively heavy. Instead we test the *pattern*
applied in P2 a single asyncio.Lock serializing any number of mixed
`add_role` and `reorg_decide` style coroutines and assert:
- Every operation completes (20 ops in our case).
- `asyncio.Lock` never produces a RuntimeError ("lock already held") which
would indicate same-task reentry.
- The log shows strictly interleaved entries (no two tasks recorded an
"inside-lock" line between each other's enter/exit).
- The whole batch completes well under the 10s budget.
Evidence: ws_handler.py:175 defines `self._config_lock = asyncio.Lock()`.
P2 wraps `_handle_reorg_decide`, `_handle_set_mode`, and the `company_profile`
swap under this same lock. The proof of no deadlock is static (D4 in the plan);
this test is a dynamic smoke that the cooperative-locking behavior holds.
"""
from __future__ import annotations
import asyncio
import time
import pytest
@pytest.mark.asyncio
async def test_add_role_and_reorg_decide_serialized():
"""20 mixed ops share one lock; no exceptions, no deadlock, finishes fast."""
lock = asyncio.Lock()
# Event log records enter/exit around the critical section so we can verify
# the lock serialized the body. If two tasks ever interleaved between an
# enter and its matching exit, the test fails.
log: list[tuple[str, str]] = [] # (op, phase)
async def guarded(op: str) -> None:
async with lock:
log.append((op, "enter"))
# Yield to event loop so other tasks get a chance to observe
# whether they see an "inside-lock" window (they should not).
await asyncio.sleep(0)
log.append((op, "exit"))
started = time.monotonic()
tasks = []
for i in range(10):
tasks.append(asyncio.create_task(guarded(f"add_role:{i}")))
tasks.append(asyncio.create_task(guarded(f"reorg_decide:{i}")))
results = await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.monotonic() - started
exceptions = [r for r in results if isinstance(r, BaseException)]
assert exceptions == [], f"Unexpected exceptions: {exceptions}"
# Every op must produce exactly one enter + one exit.
assert len(log) == 40, f"Expected 40 log entries (20 ops × 2), got {len(log)}"
# Verify lock-mutual-exclusion: iterating the log, each enter must be
# immediately followed by the SAME op's exit (no interleave).
i = 0
while i < len(log):
assert log[i][1] == "enter", f"Expected enter at index {i}, got {log[i]}"
assert log[i + 1] == (log[i][0], "exit"), (
f"Enter for {log[i][0]} was not immediately followed by its exit; "
f"saw {log[i + 1]} — indicates lock was not held across the body."
)
i += 2
# Counts must balance.
add_role_count = sum(1 for (op, phase) in log if phase == "enter" and op.startswith("add_role"))
reorg_count = sum(1 for (op, phase) in log if phase == "enter" and op.startswith("reorg_decide"))
assert add_role_count == 10
assert reorg_count == 10
# 10s budget; a correct cooperative-lock implementation finishes in <1s.
assert elapsed < 10.0, f"Test took {elapsed:.2f}s, exceeded 10s budget"
@pytest.mark.asyncio
async def test_lock_is_not_reentrant_from_same_task():
"""asyncio.Lock must NOT be re-acquirable from the same task (would deadlock).
This is the invariant that the P2 plan relied on (D4 deadlock proof): if any
code path under `_handle_reorg_decide` re-acquired `_config_lock` we'd
deadlock. Here we verify the Lock primitive's behavior — the test passes
only when a re-acquire attempt blocks forever (we force it to time out).
"""
lock = asyncio.Lock()
async def try_reentrant():
async with lock:
# Attempting to acquire again from the same task must block.
try:
await asyncio.wait_for(lock.acquire(), timeout=0.2)
except asyncio.TimeoutError:
return "blocked_as_expected"
# Only reach here if stdlib ever became reentrant (it is not).
lock.release()
return "unexpected_reentry"
outcome = await try_reentrant()
assert outcome == "blocked_as_expected", (
f"Expected asyncio.Lock to block on same-task re-acquire, got {outcome}"
)
+3 -2
View File
@@ -377,7 +377,8 @@ async def test_ui_project_switch_uses_delegate_without_cancelling_background_tas
await asyncio.sleep(0) await asyncio.sleep(0)
root._get_project_delegate.assert_awaited_once_with("project-b") root._get_project_delegate.assert_awaited_once_with("project-b")
assert handler.engine is root # Project switch deliberately rebinds the handler to the delegate.
assert handler.engine is delegate
assert handler._client_project_ids[ws] == "project-b" assert handler._client_project_ids[ws] == "project-b"
assert handler._client_switch_seq[ws] == "seq-1" assert handler._client_switch_seq[ws] == "seq-1"
sent_types = [call.args[0]["type"] for call in ws.send_json.await_args_list] sent_types = [call.args[0]["type"] for call in ws.send_json.await_args_list]
@@ -748,6 +749,7 @@ async def test_kanban_create_task_routes_by_request_project_id() -> None:
engine_b = _ui_engine("project-b", _MemoryStore([])) engine_b = _ui_engine("project-b", _MemoryStore([]))
chat_store = _ui_chat_store() chat_store = _ui_chat_store()
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter()) handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
engine_a._get_project_delegate = AsyncMock(return_value=engine_b)
handler._engine_for_project = AsyncMock( handler._engine_for_project = AsyncMock(
side_effect=lambda project_id: engine_b if project_id == "project-b" else engine_a, side_effect=lambda project_id: engine_b if project_id == "project-b" else engine_a,
) )
@@ -877,7 +879,6 @@ def test_explicit_agent_overrides_company_role_agent_defaults() -> None:
runtime_topology=topology, runtime_topology=topology,
decision=decision, decision=decision,
project_id="proj", project_id="proj",
role_agent_overrides={"ceo": "codex"},
) )
seat = enriched["seats"][0] seat = enriched["seats"][0]
+66 -35
View File
@@ -2,51 +2,82 @@ from __future__ import annotations
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import MagicMock, patch
from opc.core.config import OPCConfig, RoleConfig from opc.core.config import OPCConfig, RoleConfig
class UpdateRoleHandlerTests(unittest.IsolatedAsyncioTestCase): def _make_role() -> RoleConfig:
return RoleConfig(
id="student",
name="Student",
responsibility="Learn.",
reports_to="owner",
tools=["file_read"],
)
def _make_context(cfg: OPCConfig, *, exec_mode: str, company_profile: str):
from opc.plugins.office_ui.services.context import ModeState, OfficeServiceContext
engine = SimpleNamespace(config=cfg, org_engine=MagicMock())
context = OfficeServiceContext(
engine=engine,
agent_store=None,
chat_store=MagicMock(),
event_adapter=MagicMock(),
mode_state=ModeState(exec_mode=exec_mode, company_profile=company_profile),
)
return engine, context
class UpdateRolePersistenceTests(unittest.IsolatedAsyncioTestCase):
async def test_update_role_persists_tools(self) -> None: async def test_update_role_persists_tools(self) -> None:
from opc.plugins.office_ui.ws_handler import WSHandler """Role tool edits persist when the active org is an editable custom org.
Role mutation now lives in OrgService (the WS handler delegates to it),
and writes are intentionally refused for built-in read-only orgs, so
persistence must be asserted against an editable custom org.
"""
from opc.plugins.office_ui.services.org import OrgService
cfg = OPCConfig() cfg = OPCConfig()
cfg.org.roles = [ cfg.org.company_profile = "custom"
RoleConfig( cfg.org.organization_id = "lab_org"
id="student", cfg.org.organization_name = "Lab Org"
name="Student", cfg.org.roles = [_make_role()]
responsibility="Learn.", engine, context = _make_context(cfg, exec_mode="custom", company_profile="custom")
reports_to="owner",
tools=["file_read"],
)
]
handler = WSHandler.__new__(WSHandler)
handler.engine = SimpleNamespace(config=cfg, org_engine=MagicMock())
handler._clients = set()
handler._shutting_down = False
handler._ws_is_open = lambda _ws: True
handler._config_lock = AsyncMock()
handler._config_lock.__aenter__ = AsyncMock(return_value=None)
handler._config_lock.__aexit__ = AsyncMock(return_value=None)
handler._broadcast_org_info = AsyncMock()
ws = AsyncMock()
with patch.object(OPCConfig, "save", autospec=True) as save: with patch.object(OPCConfig, "save", autospec=True) as save:
await handler._handle_update_role( result = await OrgService(context).update_role(
ws, "student",
{ {"tools": ["file_read", " ", "web_search", ""]},
"role_id": "student",
"tools": ["file_read", " ", "web_search", ""],
},
) )
self.assertEqual(cfg.org.roles[0].tools, ["file_read", "web_search"]) self.assertEqual(cfg.org.roles[0].tools, ["file_read", "web_search"])
handler.engine.org_engine.reload_from_config.assert_called_once()
save.assert_called_once_with(cfg) save.assert_called_once_with(cfg)
handler._broadcast_org_info.assert_awaited_once() engine.org_engine.reload_from_config.assert_called_once()
ws.send_json.assert_awaited() self.assertEqual(result.payload["action"], "role_updated")
payload = ws.send_json.call_args.args[0] self.assertEqual(result.payload["role_id"], "student")
self.assertEqual(payload["type"], "ack") self.assertEqual(result.payload["role"]["tools"], ["file_read", "web_search"])
self.assertTrue(payload["payload"]["ok"])
async def test_update_role_tools_rejected_for_readonly_builtin_org(self) -> None:
"""Built-in (corporate) orgs are read-only: tool edits must not persist."""
from opc.plugins.office_ui.services.models import ServiceError
from opc.plugins.office_ui.services.org import OrgService
cfg = OPCConfig()
cfg.org.roles = [_make_role()]
engine, context = _make_context(cfg, exec_mode="company", company_profile="corporate")
with patch.object(OPCConfig, "save", autospec=True) as save:
with self.assertRaises(ServiceError) as raised:
await OrgService(context).update_role(
"student",
{"tools": ["file_read", "web_search"]},
)
self.assertEqual(raised.exception.code, "org_read_only")
self.assertEqual(cfg.org.roles[0].tools, ["file_read"])
save.assert_not_called()
engine.org_engine.reload_from_config.assert_not_called()
+22 -9
View File
@@ -202,14 +202,13 @@ class SessionContextCompressionTests(unittest.IsolatedAsyncioTestCase):
store = OPCStore(db_path) store = OPCStore(db_path)
await store.initialize() await store.initialize()
memory = MemoryManager(root, "proj1", store=store) memory = MemoryManager(root, "proj1", store=store)
memory.set_history_compactor( compactor = HistoryCompactor(
HistoryCompactor( llm=_StubLLM(),
llm=_StubLLM(), store=store,
store=store, memory_manager=memory,
memory_manager=memory, compression_threshold=0.85,
compression_threshold=0.85,
)
) )
memory.set_history_compactor(compactor)
session_id = "session-restart" session_id = "session-restart"
for idx in range(6): for idx in range(6):
@@ -217,6 +216,20 @@ class SessionContextCompressionTests(unittest.IsolatedAsyncioTestCase):
payload = f"message {idx} " + ("alpha beta gamma delta " * 40) payload = f"message {idx} " + ("alpha beta gamma delta " * 40)
await memory.append_session_message(session_id=session_id, role=role, text=payload) await memory.append_session_message(session_id=session_id, role=role, text=payload)
# Append no longer auto-compacts; compaction is an explicit call.
# Forced compaction consumes everything up to the latest message,
# so the raw tail comes from messages appended afterwards.
compacted = await compactor.maybe_compact_session(
project_id="proj1",
session_id=session_id,
force=True,
)
self.assertTrue(compacted)
for idx in range(6, 8):
role = "user" if idx % 2 == 0 else "assistant"
payload = f"message {idx} " + ("alpha beta gamma delta " * 40)
await memory.append_session_message(session_id=session_id, role=role, text=payload)
snapshot = await store.get_latest_session_memory_snapshot(session_id) snapshot = await store.get_latest_session_memory_snapshot(session_id)
self.assertIsNotNone(snapshot) self.assertIsNotNone(snapshot)
assert snapshot is not None assert snapshot is not None
@@ -225,7 +238,7 @@ class SessionContextCompressionTests(unittest.IsolatedAsyncioTestCase):
context = await memory.build_session_prompt_context(session_id) context = await memory.build_session_prompt_context(session_id)
self.assertIn("## Session Memory", context) self.assertIn("## Session Memory", context)
self.assertIn("Session history summary before restart", context) self.assertIn("Session history summary before restart", context)
self.assertIn("message 5", context) self.assertIn("message 7", context)
await store.close() await store.close()
@@ -236,7 +249,7 @@ class SessionContextCompressionTests(unittest.IsolatedAsyncioTestCase):
restarted_context = await restarted_memory.build_session_prompt_context(session_id) restarted_context = await restarted_memory.build_session_prompt_context(session_id)
self.assertTrue(any("Session history summary before restart" in item["content"] for item in restarted_history)) self.assertTrue(any("Session history summary before restart" in item["content"] for item in restarted_history))
self.assertTrue(any("message 5" in item["content"] for item in restarted_history)) self.assertTrue(any("message 7" in item["content"] for item in restarted_history))
self.assertFalse(any("message 0" in item["content"] for item in restarted_history)) self.assertFalse(any("message 0" in item["content"] for item in restarted_history))
self.assertNotIn("message 0", restarted_context) self.assertNotIn("message 0", restarted_context)
+5 -2
View File
@@ -328,7 +328,7 @@ class WorkItemRuntimeLinkTests(unittest.IsolatedAsyncioTestCase):
{item.work_item_id: canonical.id}, {item.work_item_id: canonical.id},
) )
async def test_task_delete_cascades_link_and_allows_rematerialization(self) -> None: async def test_task_delete_cascades_link_and_work_item_row(self) -> None:
item = _work_item("wi-cascade") item = _work_item("wi-cascade")
task = _task("task-cascade") task = _task("task-cascade")
await self.store.save_delegation_work_item(item) await self.store.save_delegation_work_item(item)
@@ -337,10 +337,13 @@ class WorkItemRuntimeLinkTests(unittest.IsolatedAsyncioTestCase):
await self.store.hard_delete_task(task.id) await self.store.hard_delete_task(task.id)
# Hard delete removes the linked work item row together with the
# runtime traces, so the identity cannot be rematerialized.
self.assertEqual(await self.store.get_runtime_links_for_work_items([item.work_item_id]), {}) self.assertEqual(await self.store.get_runtime_links_for_work_items([item.work_item_id]), {})
self.assertIsNone(await self.store.get_delegation_work_item(item.work_item_id))
replacement = _task("task-cascade-replacement") replacement = _task("task-cascade-replacement")
await self.store.save_task(replacement) await self.store.save_task(replacement)
self.assertTrue(await self.store.link_work_item_runtime_task(item.work_item_id, replacement.id)) self.assertFalse(await self.store.link_work_item_runtime_task(item.work_item_id, replacement.id))
async def test_transition_from_task_uses_structured_link_without_legacy_metadata(self) -> None: async def test_transition_from_task_uses_structured_link_without_legacy_metadata(self) -> None:
item = _work_item("wi-transition") item = _work_item("wi-transition")