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
+44
View File
@@ -1423,6 +1423,14 @@ class CompanyWorkItemExecutor:
# waits on this Event so children are claimed+spawned without
# waiting for the parent turn's gather batch to drain.
self._dispatcher_wake = asyncio.Event()
# Single-dispatcher-per-run invariant: refcount of live
# _execute_multi_team_org loops keyed by delegation run id.
# Checkpoint answers consult this via wake_live_run_dispatcher —
# a live run receives input in place instead of a second
# _execute_company_mode entry (re-entry reset live claim
# registries and the attempt ledger stamped every in-flight
# card as interrupted).
self._live_run_dispatchers: dict[str, int] = {}
if communication is not None and getattr(communication, "on_work_items_created", None) is None:
communication.on_work_items_created = self._signal_dispatcher_wake
# D2: register the wake callback with the phase-transition hook
@@ -4580,6 +4588,31 @@ class CompanyWorkItemExecutor:
if ownership is not None:
ownership.release()
def wake_live_run_dispatcher(self, run_id: str) -> bool:
"""Wake the live dispatcher for ``run_id``; False when none is live.
Callers must have already persisted whatever the dispatcher should
pick up (task status, work-item phase, user input) the wake is
only a scheduling nudge. This is how checkpoint answers reach a
live run without starting a second ``_execute_company_mode`` over
it (the single-dispatcher-per-run invariant).
"""
clean = str(run_id or "").strip()
if not clean or self._live_run_dispatchers.get(clean, 0) <= 0:
return False
self._signal_dispatcher_wake()
return True
@staticmethod
def _delegation_run_id_for_tasks(tasks: list[Task]) -> str:
for task in tasks:
run_id = str(
(getattr(task, "metadata", {}) or {}).get("delegation_run_id", "") or ""
).strip()
if run_id:
return run_id
return ""
async def _execute_multi_team_org(
self,
plan: CompanyWorkItemRuntimePlan,
@@ -4589,9 +4622,20 @@ class CompanyWorkItemExecutor:
CompanyExecutorRunState(active_plan=plan, active_tasks=list(tasks))
)
runtime_token = self.runtime.use_state(self.runtime.create_state())
run_id = self._delegation_run_id_for_tasks(tasks)
if run_id:
self._live_run_dispatchers[run_id] = (
self._live_run_dispatchers.get(run_id, 0) + 1
)
try:
return await self._execute_multi_team_org_scoped(plan, tasks)
finally:
if run_id:
remaining = self._live_run_dispatchers.get(run_id, 0) - 1
if remaining > 0:
self._live_run_dispatchers[run_id] = remaining
else:
self._live_run_dispatchers.pop(run_id, None)
self.runtime.reset_state(runtime_token)
self._reset_run_state(run_token)