fix(engine): single live dispatcher per run + unified approval decision channel

OBS-4: checkpoint answers for a run whose dispatcher is still live no longer
re-enter _execute_company_mode (re-entry reset live claim registries and the
attempt ledger stamped in-flight cards interrupted until the streak limit
killed them). The executor keeps a _live_run_dispatchers refcount; task and
peer checkpoint resumes deliver the input in place and wake the dispatcher.
Runs without a live dispatcher keep the original re-entry resume semantics.

OBS-7: approval decisions expressed through the chat/checkpoint route now
reach the approval engine instead of being parsed as plain task input
(which _ask_user treated as deny, re-escalating until the card died).
normalize_escalation_reply maps decision tokens/synonyms (never silently
denies free text), escalation_context_for_blocked_tool rebuilds the
allowlist context from pause_request.permission_context, and
_resume_task_checkpoint applies the grant via
apply_deferred_escalation_decision — the same engine path as the UI card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-28 10:55:55 +08:00
parent 17e9a7644d
commit 14ee8806de
5 changed files with 514 additions and 3 deletions
+149
View File
@@ -0,0 +1,149 @@
"""Regression: the unified approval decision channel (OBS-7).
An approval escalation that outlives its inline wait survives as a
``task_user_input`` checkpoint whose payload carries the runtime block's
``permission_context``. A reply that expresses a decision must reach the
approval engine — ``normalize_escalation_reply`` maps human phrasing to a
decision token, ``escalation_context_for_blocked_tool`` rebuilds the
allowlist context, and ``apply_deferred_escalation_decision`` persists the
grant exactly like the Office UI card click. Non-decision text stays plain
task input (never a silent deny).
"""
from __future__ import annotations
import unittest
from opc.core.config import AutonomyConfig
from opc.core.models import CompanyMemberSession, Task
from opc.layer2_organization.approval import (
ApprovalEngine,
normalize_escalation_reply,
)
from opc.layer2_organization.company_mode import CompanyWorkItemExecutor
class _PreferencesStub:
def get_autonomy_preferences(self, project_id=None):
_ = project_id
return {"learned_actions": {}}
def record_autonomy_feedback(self, **kwargs):
_ = kwargs
class _StoreStub:
async def record_approval(self, **kwargs):
_ = kwargs
class _MemoryStub:
def append_autonomy_event(self, event, project=False):
_ = (event, project)
def _engine() -> ApprovalEngine:
return ApprovalEngine(
llm=object(),
store=_StoreStub(),
preferences=_PreferencesStub(),
memory=_MemoryStub(),
escalation=None,
config=AutonomyConfig(),
)
class NormalizeEscalationReplyTests(unittest.TestCase):
def test_exact_tokens_pass_through(self) -> None:
for token in ("approve_once", "approve_session", "always_project",
"always_global", "deny"):
self.assertEqual(normalize_escalation_reply(token), token)
def test_approve_synonyms_map_to_approve_once(self) -> None:
for text in ("approve", "Yes", " y ", "同意", "允许"):
self.assertEqual(normalize_escalation_reply(text), "approve_once")
def test_deny_synonyms_map_to_deny(self) -> None:
for text in ("no", "Reject", "拒绝"):
self.assertEqual(normalize_escalation_reply(text), "deny")
def test_plain_content_is_not_a_decision(self) -> None:
for text in ("", "please use conda instead", "proceed with best judgment"):
self.assertEqual(normalize_escalation_reply(text), "")
class EscalationContextForBlockedToolTests(unittest.TestCase):
def test_shell_exec_context_carries_command_patterns(self) -> None:
engine = _engine()
task = Task(id="t1", title="t", project_id="p1", session_id="s1")
context = engine.escalation_context_for_blocked_tool(
task,
tool_name="shell_exec",
arguments={"command": "pip install pandas"},
)
self.assertEqual(context["action_kind"], "tool")
self.assertEqual(context["action_name"], "shell_exec")
self.assertEqual(context["project_id"], "p1")
self.assertTrue(context["allowlist_enabled"])
self.assertTrue(context["candidates"])
self.assertTrue(
any("pip" in str(item) for item in context["candidates"]),
context["candidates"],
)
def test_decision_applies_through_deferred_channel(self) -> None:
engine = _engine()
task = Task(id="t2", title="t", project_id="p1", session_id="s1")
task.metadata["session_scope_id"] = "scope-1"
context = engine.escalation_context_for_blocked_tool(
task,
tool_name="shell_exec",
arguments={"command": "pip install pandas"},
)
outcome = engine.apply_deferred_escalation_decision("approve_session", context)
self.assertTrue(outcome.get("approved"))
deny = engine.apply_deferred_escalation_decision("deny", context)
self.assertFalse(deny.get("approved"))
class LiveRunDispatcherRegistryTests(unittest.TestCase):
"""OBS-4: checkpoint answers wake a live dispatcher instead of re-entry."""
def _executor(self) -> CompanyWorkItemExecutor:
import asyncio
executor = CompanyWorkItemExecutor.__new__(CompanyWorkItemExecutor)
executor._live_run_dispatchers = {}
executor._dispatcher_wake = asyncio.Event()
return executor
def test_wake_returns_false_when_no_live_dispatcher(self) -> None:
executor = self._executor()
self.assertFalse(executor.wake_live_run_dispatcher("run-1"))
self.assertFalse(executor._dispatcher_wake.is_set())
def test_wake_signals_live_dispatcher(self) -> None:
executor = self._executor()
executor._live_run_dispatchers["run-1"] = 1
self.assertTrue(executor.wake_live_run_dispatcher("run-1"))
self.assertTrue(executor._dispatcher_wake.is_set())
def test_run_id_extraction_prefers_first_tagged_task(self) -> None:
t1 = Task(id="a", title="a", project_id="p", session_id="s")
t2 = Task(id="b", title="b", project_id="p", session_id="s")
t2.metadata["delegation_run_id"] = "run-9"
self.assertEqual(
CompanyWorkItemExecutor._delegation_run_id_for_tasks([t1, t2]),
"run-9",
)
self.assertEqual(
CompanyWorkItemExecutor._delegation_run_id_for_tasks([t1]),
"",
)
def test_member_session_import_smoke(self) -> None:
# Guard against accidental import regressions in the test module.
self.assertTrue(CompanyMemberSession)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,155 @@
"""Engine-integration regression for OBS-4 + OBS-7.
A ``task_user_input`` answer for a run whose dispatcher is live must:
1. apply an explicit approval decision through the approval engine
(OBS-7 — the chat route was input-only, so the blocked tool
re-escalated until the attempt ledger killed the card), and
2. deliver the input in place and wake the live dispatcher instead of
re-entering ``_execute_company_mode`` (OBS-4 — re-entry reset live
claim registries and the ledger stamped in-flight cards interrupted).
When no dispatcher is live the resume must fall through to the legacy
re-entry path unchanged.
"""
from __future__ import annotations
import asyncio
import unittest
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import AsyncMock
from opc.core.config import AutonomyConfig
from opc.core.models import ExecutionCheckpoint, Task, TaskStatus
from opc.database.store import OPCStore
from opc.engine import OPCEngine
from opc.layer2_organization.approval import ApprovalEngine
from opc.layer2_organization.company_mode import CompanyWorkItemExecutor
from opc.layer2_organization.work_item_links import set_linked_work_item_id
class _PreferencesStub:
def get_autonomy_preferences(self, project_id=None):
_ = project_id
return {"learned_actions": {}}
def record_autonomy_feedback(self, **kwargs):
_ = kwargs
class _StoreStub:
async def record_approval(self, **kwargs):
_ = kwargs
class _MemoryStub:
def append_autonomy_event(self, event, project=False):
_ = (event, project)
def _approval_engine() -> ApprovalEngine:
return ApprovalEngine(
llm=object(),
store=_StoreStub(),
preferences=_PreferencesStub(),
memory=_MemoryStub(),
escalation=None,
config=AutonomyConfig(),
)
class CheckpointAnswerLiveDispatcherTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self._tmp = TemporaryDirectory()
self.store = OPCStore(Path(self._tmp.name) / "tasks.db")
await self.store.initialize()
self.engine = OPCEngine(project_id="p")
self.engine.store = self.store
self.engine.approval_engine = _approval_engine()
self.executor = CompanyWorkItemExecutor.__new__(CompanyWorkItemExecutor)
self.executor._live_run_dispatchers = {}
self.executor._dispatcher_wake = asyncio.Event()
self.engine.company_executor = self.executor
self.engine._execute_company_mode = AsyncMock(return_value="re-entered")
self.engine._execute_single_agent = AsyncMock(return_value="single-agent")
async def asyncTearDown(self) -> None:
await self.store.close()
self._tmp.cleanup()
async def _seed(self) -> ExecutionCheckpoint:
task = Task(
id="task-1",
title="blocked worker",
project_id="p",
session_id="s",
status=TaskStatus.AWAITING_HUMAN,
metadata={
"delegation_run_id": "run-1",
"execution_mode": "company_mode",
"work_item_runtime": True,
},
)
set_linked_work_item_id(task, "wi-1")
await self.store.save_task(task)
checkpoint = ExecutionCheckpoint(
checkpoint_id="ckpt-1",
project_id="p",
session_id="s",
checkpoint_type="task_user_input",
task_id="task-1",
status="pending",
payload={
"task_id": "task-1",
"session_id": "s",
"execution_mode": "company_mode",
"task_ids": ["task-1"],
"prompt": "Tool execution blocked by autonomy policy",
"pause_request": {
"requires_user_input": True,
"permission_context": {
"tool_name": "shell_exec",
"candidate": "pip install pandas",
"resolution": "ask",
},
},
},
created_at=datetime.now(),
)
await self.store.save_execution_checkpoint(checkpoint)
return checkpoint
async def test_live_dispatcher_gets_wake_and_approval_applies(self) -> None:
checkpoint = await self._seed()
self.executor._live_run_dispatchers["run-1"] = 1
reply = await self.engine._resume_task_checkpoint(checkpoint, "approve_session")
self.assertIn("live", reply)
self.assertTrue(self.executor._dispatcher_wake.is_set())
self.engine._execute_company_mode.assert_not_awaited()
self.engine._execute_single_agent.assert_not_awaited()
saved = await self.store.get_task("task-1")
injected = str(saved.context_snapshot.get("user_supplied_input", ""))
self.assertIn("Approval decision applied", injected)
self.assertIn("shell_exec", injected)
self.assertEqual(saved.status, TaskStatus.PENDING)
async def test_no_live_dispatcher_falls_through_to_reentry_path(self) -> None:
checkpoint = await self._seed()
reply = await self.engine._resume_task_checkpoint(checkpoint, "please continue")
self.assertFalse(self.executor._dispatcher_wake.is_set())
self.assertEqual(reply, "single-agent")
self.engine._execute_single_agent.assert_awaited()
saved = await self.store.get_task("task-1")
self.assertEqual(
saved.context_snapshot.get("user_supplied_input"),
"please continue",
)
if __name__ == "__main__":
unittest.main()