Files
OpenOPC/opc/layer2_organization/work_item_transition.py
T
2026-07-01 17:56:31 +08:00

740 lines
32 KiB
Python

"""Single authoritative entry point for WorkItem phase changes.
This module exposes ``transition_work_item`` — the only function in the
codebase that should mutate a ``DelegationWorkItem.phase``. Its purpose is
to centralise the "phase is the single source of truth" invariant that
``phase.py`` always claimed but code never enforced.
Everything downstream of phase (``Task.status``, ``DelegationRoleSession
.status`` (DB), ``CompanyMemberSession.status`` (memory), UI kanban
column) is synced via the registered phase-transition hooks, so callers
only need to think about "what phase should this card be in now".
Direct writes like ``task.status = TaskStatus.CANCELLED`` or
``session.status = "idle"`` bypass the hook chain and guarantee cross-layer
state desync — the exact pattern that produced the parent-resume and
stop-cascade bugs in new11 app05. See ``plans/task-key-proud-blum.md``.
This module also exposes ``refresh_dependents_for_run`` — the dependency
frontier pass that propagates child completion (or terminal state) to
parent work items. Lifted out of CompanyMode so a phase-transition hook
can invoke it on any terminal/escalation transition without an import
cycle. See Fix 3 in ``memory/company-mode-stuck-bugs.md``.
"""
from __future__ import annotations
from contextvars import ContextVar
from datetime import datetime
from typing import Any
from loguru import logger
from opc.core.models import DelegationEvent, DelegationWorkItem, Phase, Task, TaskStatus
from opc.layer2_organization.phase import (
DONE_PHASES,
InvalidPhaseTransition,
coerce_phase,
phase_for_task_status,
task_status_for_phase,
validate_transition,
)
from opc.layer2_organization.work_item_links import linked_work_item_id_for_task
from opc.layer2_organization.work_item_identity import work_item_identity_payload
from opc.layer2_organization.work_item_runtime import is_work_item_runtime_metadata
async def transition_work_item(
store: Any,
work_item_id: str,
*,
target_phase: Phase | str,
reason: str,
summary: str | None = None,
metadata_updates: dict[str, Any] | None = None,
release_claim: bool = False,
) -> DelegationWorkItem | None:
"""Transition a work item to ``target_phase``.
The function wraps ``store.update_delegation_work_item(phase=...)`` —
which already validates the transition against the state-machine table
(``ALLOWED_TRANSITIONS`` in phase.py) and fires the full
``on_phase_transition`` hook chain. This wrapper adds:
- A mandatory ``reason`` string stamped into metadata for audit
- An optional ``release_claim`` flag that clears
``claimed_by_role_runtime_session_id`` / ``claimed_by_seat_id`` so
``is_orphaned`` becomes True and the dispatcher re-picks the card
Args:
store: OPCStore instance; must expose ``update_delegation_work_item``.
work_item_id: Target work item id.
target_phase: ``Phase`` enum or string (coerced via ``coerce_phase``).
reason: Short human-readable reason; recorded in metadata for audit.
summary: Optional summary string persisted on the work item.
metadata_updates: Extra metadata keys to merge onto the work item.
release_claim: When True, clears the current claim so the dispatcher
can re-acquire. Useful for cancel / timeout / forced-release paths.
Returns:
The updated ``DelegationWorkItem``, or ``None`` when the store lacks
the required API or the work item does not exist.
"""
if not store or not hasattr(store, "update_delegation_work_item"):
logger.warning(
"transition_work_item: store lacks update_delegation_work_item"
)
return None
phase = coerce_phase(target_phase)
merged: dict[str, Any] = dict(metadata_updates or {})
reason_clean = str(reason or "").strip()
if reason_clean:
merged["last_transition_reason"] = reason_clean
kwargs: dict[str, Any] = {
"phase": phase,
"metadata_updates": merged,
}
if summary is not None:
kwargs["summary"] = summary
# Phase transition first, claim release second (when requested). The
# old rationale for this ordering was "sync_member_session_hook reads
# item.claimed_by_role_runtime_session_id"; Phase B removed that hook
# and moved the unpark to the dispatcher's per-tick rehydrate pass,
# but the two-step ordering is kept so downstream listeners that
# still inspect the claim (audit logs, kanban projections) see a
# consistent before/after.
try:
result = await store.update_delegation_work_item(work_item_id, **kwargs)
except Exception:
logger.opt(exception=True).warning(
f"transition_work_item failed wid={work_item_id} "
f"target={phase.value} reason={reason_clean}"
)
raise
if release_claim and result is not None:
try:
result = await store.update_delegation_work_item(
work_item_id,
claimed_by_role_runtime_session_id="",
claimed_by_seat_id="",
)
except Exception:
logger.opt(exception=True).warning(
f"transition_work_item: claim release failed wid={work_item_id}"
)
return result
def _fallback_status_for(
target_status_or_phase: TaskStatus | Phase | str,
task: Task,
) -> TaskStatus | None:
"""Pre-compute the TaskStatus to assign locally when the work-item
transition cannot happen (no linked work_item, no store). Mirrors the
projection the live hook would apply, so task-mode callers see the
same local result whether or not a work_item exists.
"""
try:
if isinstance(target_status_or_phase, TaskStatus):
return target_status_or_phase
if isinstance(target_status_or_phase, Phase):
return task_status_for_phase(target_status_or_phase)
if isinstance(target_status_or_phase, str):
raw = target_status_or_phase.strip().lower()
try:
return task_status_for_phase(Phase(raw))
except ValueError:
try:
return TaskStatus(raw)
except ValueError:
return None
except Exception:
return None
return None
async def transition_work_item_from_task(
store: Any,
task: Task,
*,
target_status_or_phase: TaskStatus | Phase | str,
reason: str,
summary: str | None = None,
metadata_updates: dict[str, Any] | None = None,
release_claim: bool = False,
require_work_item: bool = False,
) -> bool:
"""Task bridge helper: transition a work item when the caller holds a Task.
Designed as the replacement for direct ``task.status = ...`` writes in
company-mode code. Resolves the linked work item via the hydrated runtime
link table id (falling back to legacy metadata for old rows), coerces the
desired state into a Phase, and delegates to ``transition_work_item``.
``target_status_or_phase`` may be:
* a ``Phase`` (or phase-string) — used verbatim.
* a ``TaskStatus`` (or status-string) — projected via
``phase_for_task_status``. BLOCKED disambiguation uses
``task.metadata['delegation_pending_work_item_ids']`` to distinguish
``WAITING_FOR_CHILDREN`` (has pending children) from ``PAUSED``.
This preserves the old task-status projection semantics.
Forward-invalid transitions (e.g. a late async callback arriving after
the work item was already moved by a reviewer) are silently preserved
rather than raising. We log at DEBUG so the race is observable without
being noisy.
**Local task.status sync**: after the work item transition, the registered
``sync_task_status_hook`` updates the DB task.status. The caller still
holds a local ``task`` object whose ``status`` is now stale — and any
subsequent ``save_task(task)`` would overwrite the hook's DB update with
the stale value (race). To avoid that, we eagerly project the target
Phase back to a TaskStatus and assign it to the local ``task.status``
in-memory. This is NOT a direct DB write; it keeps the caller's in-memory
view consistent with what the DB now holds.
**task-mode fallback**: when there's no linked work item (task-mode
path or pre-materialization), the helper returns ``False`` but still
syncs the local ``task.status`` to the caller's intended value. This
lets company-mode call sites be migrated to this helper without each
one needing its own ``task.status = ...`` fallback — the task-mode
execution path just sees the local mutation and a subsequent
``save_task(task)`` by the caller persists it.
``require_work_item=True`` is for company-mode runtime call sites where
falling back to a local Task.status write would reintroduce drift. In
that mode missing store/link returns ``False`` without mutating local
status.
Returns ``True`` when a work-item transition was issued (including the
silent-degrade no-op case). Returns ``False`` when there is no linked
work item; the local ``task.status`` is only synced when
``require_work_item`` is false.
"""
# Pre-resolve the fallback status so task-mode / pre-materialization
# paths still end up with a synced local task.status before we bail.
fallback_status = _fallback_status_for(target_status_or_phase, task)
if not store or not hasattr(store, "update_delegation_work_item"):
if fallback_status is not None and not require_work_item:
task.status = fallback_status
return False
work_item_id = linked_work_item_id_for_task(task)
if not work_item_id and hasattr(store, "get_work_item_for_runtime_task"):
try:
linked_item = await store.get_work_item_for_runtime_task(task.id)
except Exception:
linked_item = None
work_item_id = str(getattr(linked_item, "work_item_id", "") or "").strip()
if not work_item_id:
if fallback_status is not None and not require_work_item:
task.status = fallback_status
return False
# Coerce target to Phase, applying BLOCKED → WAITING_FOR_CHILDREN/PAUSED
# disambiguation from task metadata.
if isinstance(target_status_or_phase, Phase):
target_phase: Phase = target_status_or_phase
elif isinstance(target_status_or_phase, TaskStatus):
has_pending_children = bool(
(task.metadata or {}).get("delegation_pending_work_item_ids") or []
)
target_phase = phase_for_task_status(
target_status_or_phase,
has_pending_children=has_pending_children,
)
elif isinstance(target_status_or_phase, str):
raw = target_status_or_phase.strip().lower()
try:
target_phase = Phase(raw)
except ValueError:
try:
ts = TaskStatus(raw)
except ValueError as exc:
raise ValueError(
f"transition_work_item_from_task: target_status_or_phase {target_status_or_phase!r} "
"is neither a valid Phase nor TaskStatus"
) from exc
has_pending_children = bool(
(task.metadata or {}).get("delegation_pending_work_item_ids") or []
)
target_phase = phase_for_task_status(
ts, has_pending_children=has_pending_children
)
else:
raise TypeError(
"transition_work_item_from_task: target_status_or_phase must be "
f"Phase | TaskStatus | str, got {type(target_status_or_phase).__name__}"
)
# Silent-degrade guard against late async races (see docstring). Look up
# the persisted phase and preserve it if the desired transition is not
# in ALLOWED_TRANSITIONS. This keeps shared role-session callbacks
# crash-free when a late writer observes stale task state.
persisted_phase: Phase | None = None
if hasattr(store, "get_delegation_work_item"):
try:
persisted_item = await store.get_delegation_work_item(work_item_id)
except Exception:
persisted_item = None
if persisted_item is not None:
persisted_phase = getattr(persisted_item, "phase", None)
if target_phase != persisted_phase and persisted_phase is not None:
try:
validate_transition(persisted_phase, target_phase)
except InvalidPhaseTransition:
logger.debug(
"transition_work_item_from_task: preserving persisted phase "
f"{persisted_phase.value} for work_item={work_item_id} "
f"(projected {target_phase.value} would be an invalid transition)"
)
return True
# Always stamp the task-id / task-status back-reference so audit and
# reverse lookup stay consistent. Callers can layer extra metadata on top.
back_ref: dict[str, Any] = {
"task_id": task.id,
"task_status": (
target_status_or_phase.value
if isinstance(target_status_or_phase, (TaskStatus, Phase))
else str(target_status_or_phase)
),
}
if metadata_updates:
back_ref.update(metadata_updates)
try:
await transition_work_item(
store,
work_item_id,
target_phase=target_phase,
reason=reason,
summary=summary,
metadata_updates=back_ref,
release_claim=release_claim,
)
except InvalidPhaseTransition:
# Defensive: state-machine validation at the store layer can also
# raise. Degrade the same way the pre-check does, for the race
# where persisted_phase changed between our lookup and the write.
logger.debug(
f"transition_work_item_from_task: store-layer rejected "
f"{persisted_phase}{target_phase.value} for wid={work_item_id} "
"(concurrent writer); degrading to no-op."
)
return True
# Sync local task.status to match the target phase so any subsequent
# save_task(task) by the caller doesn't race with the hook's DB update.
# The assignment goes through task_status_for_phase() — not a literal
# TaskStatus.CANCELLED/FAILED — so the DirectStatusWriteLintTest regex
# doesn't flag it as a bypass.
try:
task.status = task_status_for_phase(target_phase)
except Exception:
logger.opt(exception=True).debug(
"transition_work_item_from_task: local status sync failed"
)
return True
async def apply_task_status_transition(
store: Any,
task: Task,
*,
target_status_or_phase: TaskStatus | Phase | str,
reason: str,
summary: str | None = None,
metadata_updates: dict[str, Any] | None = None,
release_claim: bool = False,
save_plain_task: bool = True,
raise_on_missing_work_item: bool = True,
) -> bool:
"""Apply a task status intent through the right source of truth.
Company WorkItem runtime tasks must transition their linked WorkItem phase;
plain task-mode tasks keep the legacy Task.status behavior through the
fallback branch in ``transition_work_item_from_task``.
"""
metadata = dict(getattr(task, "metadata", {}) or {})
company_runtime = bool(
linked_work_item_id_for_task(task)
or is_work_item_runtime_metadata(metadata)
)
transitioned = await transition_work_item_from_task(
store,
task,
target_status_or_phase=target_status_or_phase,
reason=reason,
summary=summary,
metadata_updates=metadata_updates,
release_claim=release_claim,
require_work_item=company_runtime,
)
if company_runtime:
if not transitioned and raise_on_missing_work_item:
target = (
target_status_or_phase.value
if isinstance(target_status_or_phase, (TaskStatus, Phase))
else str(target_status_or_phase)
)
raise RuntimeError(
"company runtime task cannot transition without a linked WorkItem: "
f"task={getattr(task, 'id', '')} target={target}"
)
return transitioned
if save_plain_task and store and hasattr(store, "save_task"):
await store.save_task(task)
return transitioned
# Re-entrancy guard: refresh_dependents_for_run writes to work items, which
# fires phase-transition hooks, which can re-call refresh. The outer call
# already walks every item in the run, so inner calls on the same run_id
# are redundant — silently skip them. Module-level ContextVar because the
# dispatcher runs async tasks, and we want per-task isolation.
_REFRESH_IN_FLIGHT: ContextVar[frozenset[str]] = ContextVar(
"refresh_dependents_in_flight", default=frozenset()
)
_SYNTHESIS_SKIP_KINDS: frozenset[str] = frozenset({
"aggregate",
"deliver",
"delivery",
"intake",
"review",
"synthesis",
"synthesize",
})
def _work_item_id(item: DelegationWorkItem | Any | None) -> str:
return str(getattr(item, "work_item_id", "") or "").strip()
def _dependency_replacement_ids(item: DelegationWorkItem | Any | None) -> list[str]:
metadata = dict(getattr(item, "metadata", {}) or {}) if item is not None else {}
raw = (
metadata.get("replacement_dependency_work_item_ids")
or metadata.get("replacement_work_item_ids")
or metadata.get("superseded_by_work_item_ids")
or []
)
if isinstance(raw, str):
raw = [raw]
try:
values = list(raw or [])
except TypeError:
values = [raw]
return list(dict.fromkeys(str(value).strip() for value in values if str(value).strip()))
def is_prunable_dependency_work_item(item: DelegationWorkItem | Any | None) -> bool:
"""True when a dependency target is obsolete rather than merely failed.
A normal CANCELLED/FAILED dependency is still meaningful and should keep
the parent from silently succeeding. Manager-deleted or hidden cancelled
cards are different: they are explicit graph edits, so stale references to
them must be removed or replaced whenever the dependency frontier refreshes.
"""
if item is None:
return False
metadata = dict(getattr(item, "metadata", {}) or {})
if bool(metadata.get("deleted_by_manager_tool", False)):
return True
upstream_visibility = str(metadata.get("upstream_visibility", "") or "").strip().lower()
return (
getattr(item, "phase", None) == Phase.CANCELLED
and bool(metadata.get("hidden_from_company_kanban", False))
and upstream_visibility == "hidden"
)
def normalize_dependency_work_item_ids(
raw_dependency_ids: list[str] | tuple[str, ...] | set[str],
work_item_by_id: dict[str, DelegationWorkItem | Any],
*,
owner_work_item_id: str = "",
) -> tuple[list[str], list[str]]:
"""Drop or replace stale dependency ids while preserving hard failures.
Returns ``(active_ids, pruned_ids)``. Replacement ids come from metadata on
the obsolete dependency target, and are themselves validated against the
current run graph so a deleted replacement cannot resurrect another stale
edge.
"""
owner_id = str(owner_work_item_id or "").strip()
active: list[str] = []
pruned: list[str] = []
def append_active(candidate_id: str) -> None:
candidate = str(candidate_id or "").strip()
if not candidate or candidate == owner_id:
if candidate:
pruned.append(candidate)
return
item = work_item_by_id.get(candidate)
if is_prunable_dependency_work_item(item):
pruned.append(candidate)
return
active.append(candidate)
for raw_id in list(raw_dependency_ids or []):
dep_id = str(raw_id or "").strip()
if not dep_id:
continue
item = work_item_by_id.get(dep_id)
if is_prunable_dependency_work_item(item):
pruned.append(dep_id)
for replacement_id in _dependency_replacement_ids(item):
append_active(replacement_id)
continue
append_active(dep_id)
return (
list(dict.fromkeys(active)),
list(dict.fromkeys(pruned)),
)
def _work_item_kind(item: DelegationWorkItem, metadata: dict[str, Any]) -> str:
return str(
metadata.get("work_kind")
or metadata.get("delegation_turn_kind")
or item.kind
or ""
).strip().lower()
def _should_enter_synthesis_turn(
item: DelegationWorkItem,
metadata: dict[str, Any],
dependency_ids: list[str],
) -> bool:
if item.phase != Phase.WAITING_FOR_CHILDREN:
return False
if not dependency_ids:
return False
if bool(metadata.get("synthesis_turn_started", False)):
return False
if _work_item_kind(item, metadata) in _SYNTHESIS_SKIP_KINDS:
return False
if not (
bool(metadata.get("delegated_children_pending", False))
or str(metadata.get("frontier", "") or "").strip() == "waiting_for_children"
or str(metadata.get("last_delegated_by_seat_id", "") or "").strip()
):
return False
return True
def _synthesis_turn_summary(item: DelegationWorkItem, dependency_ids: list[str]) -> str:
title = str(item.title or "delegated work").strip()
child_count = len(dependency_ids)
manager_label = str(item.manager_role_id or "the upstream owner").strip()
return (
f"Synthesize the {child_count} approved child work item"
f"{'' if child_count == 1 else 's'} for `{title}` and prepare the "
f"handoff for {manager_label}. Include what was completed, evidence, "
"remaining risks, and any decision needed from the upper role."
)
async def refresh_dependents_for_run(
store: Any,
*,
run_id: str,
source_work_item_id: str | None = None,
source_task_id: str | None = None,
source_role_id: str | None = None,
source_cell_id: str | None = None,
) -> bool:
"""Walk all work items in ``run_id`` and propagate dependency state
to parent phases.
**What this does in one pass**:
- ``WAITING_DEPENDENCIES → READY`` (or ``READY_FOR_REWORK`` when the
item carries an outstanding rework_feedback) when all deps approved.
- ``WAITING_FOR_CHILDREN → READY`` as a synthesis turn when delegated
children are all approved; otherwise ``WAITING_FOR_CHILDREN → RUNNING``.
Both paths release the parent's stale claim so the dispatcher can
re-pick it cleanly.
- Reverse direction: a RUNNING item whose deps regress (new dep
appeared) goes to ``WAITING_FOR_CHILDREN``; a READY item to
``WAITING_DEPENDENCIES``.
**Who calls this**:
1. ``CompanyMode._refresh_delegation_dependents`` — preserves the
explicit call from APPROVED-verdict paths (belt-and-suspenders).
2. ``phase_hooks.refresh_dependents_hook`` — fires on every terminal
transition (APPROVED / FAILED / CANCELLED) and on AWAITING_HUMAN,
so the frontier refreshes automatically. Without the hook, a
child escalating to AWAITING_HUMAN (or a human-approved
AWAITING_HUMAN → APPROVED click) would never unblock its parent.
Returns True when any parent was mutated (for cheap change detection
in callers that want to emit a downstream event).
"""
if not store or not run_id:
return False
if not hasattr(store, "list_delegation_work_items") or not hasattr(
store, "update_delegation_work_item"
):
return False
in_flight = _REFRESH_IN_FLIGHT.get()
if run_id in in_flight:
return False
token = _REFRESH_IN_FLIGHT.set(in_flight | {run_id})
try:
try:
work_items = await store.list_delegation_work_items(run_id)
except Exception:
logger.opt(exception=True).debug(
f"refresh_dependents_for_run: list_delegation_work_items failed run={run_id}"
)
return False
work_item_by_id = {item.work_item_id: item for item in work_items}
changed = False
for work_item in work_items:
metadata = dict(work_item.metadata or {})
raw_dependency_ids = [
str(item).strip()
for item in list(metadata.get("dependency_work_item_ids", []) or [])
if str(item).strip()
]
if not raw_dependency_ids:
continue
dependency_ids, pruned_dependency_ids = normalize_dependency_work_item_ids(
raw_dependency_ids,
work_item_by_id,
owner_work_item_id=work_item.work_item_id,
)
dependency_phases = {
dep_id: (work_item_by_id[dep_id].phase if dep_id in work_item_by_id else None)
for dep_id in dependency_ids
}
all_approved = all(p == Phase.APPROVED for p in dependency_phases.values())
target_phase = work_item.phase
metadata_updates: dict[str, Any] = {}
summary_update: str | None = None
entered_synthesis_turn = False
if dependency_ids != raw_dependency_ids:
metadata_updates["dependency_work_item_ids"] = list(dependency_ids)
metadata_updates["dependency_pruned_at"] = datetime.now().isoformat()
if pruned_dependency_ids:
previous_pruned = [
str(item).strip()
for item in list(metadata.get("pruned_dependency_work_item_ids", []) or [])
if str(item).strip()
]
metadata_updates["pruned_dependency_work_item_ids"] = list(
dict.fromkeys([*previous_pruned, *pruned_dependency_ids])
)
if all_approved:
if _should_enter_synthesis_turn(work_item, metadata, dependency_ids):
entered_synthesis_turn = True
target_phase = Phase.READY
summary_update = _synthesis_turn_summary(work_item, dependency_ids)
previous_kind = _work_item_kind(work_item, metadata)
metadata_updates.update(
{
"pre_synthesis_work_kind": previous_kind,
"work_kind": "synthesize",
"delegation_turn_kind": "synthesize",
**work_item_identity_payload(
projection_id=str(work_item.projection_id or work_item.work_item_id or ""),
turn_type="aggregate",
),
"current_turn_mode": "synthesize_required",
"synthesis_turn_started": True,
"synthesis_ready_at": datetime.now().isoformat(),
"synthesis_source_work_item_ids": list(dependency_ids),
"synthesis_reports_to_role_id": str(work_item.manager_role_id or "").strip(),
"synthesis_reports_to_seat_id": str(work_item.manager_seat_id or "").strip(),
"frontier": "synthesis_ready",
"needs_manager_attention": False,
}
)
elif work_item.phase == Phase.WAITING_DEPENDENCIES:
target_phase = (
Phase.READY_FOR_REWORK
if str(metadata.get("rework_feedback", "") or "").strip()
else Phase.READY
)
elif work_item.phase == Phase.WAITING_FOR_CHILDREN:
target_phase = Phase.RUNNING
if _work_item_kind(work_item, metadata) in {"deliver", "delivery"}:
metadata_updates.update(
{
"work_kind": "delivery",
"delegation_turn_kind": "delivery",
**work_item_identity_payload(
projection_id=str(work_item.projection_id or work_item.work_item_id or ""),
turn_type="deliver",
),
"current_turn_mode": "deliver_required",
"delivery_turn_ready_at": datetime.now().isoformat(),
}
)
metadata_updates["waiting_on_work_item_ids"] = []
if metadata.get("delegated_children_pending"):
metadata_updates["delegated_children_pending"] = False
if str(metadata.get("frontier", "") or "") == "waiting_for_children" and not entered_synthesis_turn:
metadata_updates["frontier"] = "resumed"
else:
if work_item.phase == Phase.READY:
target_phase = Phase.WAITING_DEPENDENCIES
elif work_item.phase == Phase.RUNNING:
target_phase = Phase.WAITING_FOR_CHILDREN
metadata_updates["waiting_on_work_item_ids"] = dependency_ids
# Clear the parent claim whenever the parent truly leaves
# WAITING_FOR_CHILDREN toward a non-terminal phase. The old
# condition ("only when all children approved AND target is
# RUNNING") left a gap: when a child went READY_FOR_REWORK,
# the refresh now fires (per _DEPENDENT_REFRESH_TARGETS) but
# the parent stayed in WAITING_FOR_CHILDREN with a stale claim,
# so the dispatcher couldn't re-pick it even though the child
# was back on the worker's queue.
# We exclude DONE_PHASES because for terminal parents the
# claim is a historical audit record of "last executor".
clear_claim_on_wake = (
work_item.phase == Phase.WAITING_FOR_CHILDREN
and target_phase != work_item.phase
and target_phase not in DONE_PHASES
)
if target_phase != work_item.phase or metadata_updates or clear_claim_on_wake:
try:
await store.update_delegation_work_item(
work_item.work_item_id,
phase=target_phase if target_phase != work_item.phase else None,
blocked_reason="" if all_approved else None,
metadata_updates=metadata_updates or None,
summary=summary_update,
claimed_by_role_runtime_session_id="" if clear_claim_on_wake else None,
claimed_by_seat_id="" if clear_claim_on_wake else None,
)
changed = True
except Exception:
logger.opt(exception=True).debug(
"refresh_dependents_for_run: update_delegation_work_item failed "
f"wid={work_item.work_item_id}"
)
if changed and hasattr(store, "save_delegation_event"):
try:
await store.save_delegation_event(
DelegationEvent(
run_id=run_id,
work_item_id=source_work_item_id or None,
cell_id=source_cell_id or None,
role_id=source_role_id or None,
event_type="dependency_frontier_refreshed",
payload={
"source_task_id": source_task_id,
"source_work_item_id": source_work_item_id,
},
)
)
except Exception:
logger.opt(exception=True).debug(
"refresh_dependents_for_run: event persistence failed"
)
return changed
finally:
_REFRESH_IN_FLIGHT.reset(token)