Files
OpenOPC/opc/layer2_organization/phase_hooks.py
T

824 lines
32 KiB
Python

"""Phase-transition hooks.
Each hook subscribes to `on_phase_transition` from `phase.py` and is
responsible for ONE downstream layer:
sync_task_status_hook → tasks.status follows work_item.phase
signal_dispatcher_hook → kick the dispatcher loop awake when a
transition opens new dispatchable work
refresh_dependents_hook → propagate child terminal/escalation
transitions up the dep graph so stuck
leaders wake (Fix 3)
Phase B note: the old ``wake_parent_on_resume_hook`` /
``sync_member_session_hook`` / ``_REENQUEUE_WORK_ITEM_HOOKS`` /
``_RUNTIME_RECONCILER_HOOKS`` machinery has been removed. Parent wake
and rework dispatch are now handled by the dispatcher's per-tick
rehydrate pass (``CompanyMode._execute_multi_team_org`` unparks stale
member sessions + re-enqueues runnable work items from the DB every
iteration). Keeping the DB as the single source of truth and having
the dispatcher converge on each tick replaces three layers of
cross-layer sync hooks — much less to break.
"""
from __future__ import annotations
from typing import Any
from loguru import logger
from opc.core.models import Phase, TaskStatus
from opc.layer2_organization.phase import (
DONE_PHASES,
RUNNABLE_PHASES,
is_stale_claim_releasable,
register_phase_transition_hook,
task_status_for_phase,
)
from opc.layer2_organization.work_item_transition import refresh_dependents_for_run
# Module-level singleton for dispatcher signalling. CompanyMode populates
# this in its constructor with a callable that sets the wake event. We
# keep it as a list (rather than a single callable) so multiple engines
# can coexist (rare, but happens in tests).
_DISPATCHER_WAKE_HOOKS: list[Any] = []
def register_dispatcher_wake(callback: Any) -> None:
"""CompanyMode calls this to register its `_signal_dispatcher_wake`
so the phase-transition hook can fire it without an import cycle."""
if callback not in _DISPATCHER_WAKE_HOOKS:
_DISPATCHER_WAKE_HOOKS.append(callback)
def unregister_dispatcher_wake(callback: Any) -> None:
try:
_DISPATCHER_WAKE_HOOKS.remove(callback)
except ValueError:
pass
# ── Hook 1: task.status follows work_item.phase ──────────────────────────
async def sync_task_status_hook(
previous: Phase | None,
target: Phase,
item: Any,
*,
store: Any,
) -> None:
"""Whenever a work-item phase changes, project the new TaskStatus and
persist it on the linked task. Without this, task.status drifts from
work_item.phase (the bug behind the app04 deadlock — task remained
BLOCKED even after work_item moved WAITING_FOR_CHILDREN → RUNNING).
Company-mode boundary: this hook keeps the internal runtime Task aligned
with the WorkItem; it does not make Task a second business-state owner.
"""
# Even idempotent work-item saves should project the phase back onto
# the linked task. This repairs task/work_item drift when an older
# task row is still awaiting review after the work item already
# reached a terminal phase.
if not hasattr(store, "get_task") or not hasattr(store, "save_task"):
return
try:
task = None
get_runtime_task = getattr(store, "get_runtime_task_for_work_item", None)
if callable(get_runtime_task):
task = await get_runtime_task(str(getattr(item, "work_item_id", "") or "").strip())
except Exception:
return
if task is None:
return
desired_status = task_status_for_phase(target)
if task.status == desired_status:
return
task.status = desired_status
try:
await store.save_task(task)
except Exception:
logger.opt(exception=True).debug("sync_task_status_hook: save_task failed")
# ── Hook 2: kick the dispatcher when a transition opens dispatchable work ─
_DISPATCHER_WAKE_TARGETS = RUNNABLE_PHASES | DONE_PHASES
async def signal_dispatcher_hook(
previous: Phase | None,
target: Phase,
item: Any,
*,
store: Any,
) -> None:
"""Fire the dispatcher's wake event whenever a phase transition
opens the door to new work or unblocks downstream dependents.
Specifically: any transition to RUNNABLE_PHASES (READY /
READY_FOR_REWORK) or DONE_PHASES (children-done propagation) should
immediately ping the loop. Without this, the dispatcher only sees
the change on its next periodic tick (slower UX).
"""
if previous == target:
return
if target not in _DISPATCHER_WAKE_TARGETS and target != Phase.RUNNING:
# Allow RUNNING because parent-unblock transitions to RUNNING and
# the dispatcher needs to re-pick the parent.
return
for cb in list(_DISPATCHER_WAKE_HOOKS):
try:
cb()
except Exception:
logger.opt(exception=True).debug("dispatcher wake callback raised")
# ── Hook 3: propagate child terminal/escalation to parent dep frontier ──
# Which transitions can unblock or re-evaluate a parent.
# APPROVED — may make parent's all_approved true → WAITING_FOR_CHILDREN → RUNNING.
# FAILED / CANCELLED — parent should see the child exited; same refresh pass
# rewrites waiting_on_work_item_ids and lets higher-level policy decide next.
# AWAITING_HUMAN — a human needs to act on this child; the frontier refresh
# keeps parent metadata consistent (e.g. waiting_on_work_item_ids). When the
# human subsequently approves (AWAITING_HUMAN → APPROVED), that transition
# also fires this hook and finally unblocks the parent.
# READY_FOR_REWORK — child was sent back to worker by reviewer. Parent's
# waiting_on_work_item_ids is stale in the opposite direction (the child is
# no longer "done" from parent's perspective) and parent's claim may be
# holding the parent's session hostage. Refresh keeps the frontier accurate
# and triggers claim release on wake (see clear_claim_on_wake in
# work_item_transition.refresh_dependents_for_run).
_DEPENDENT_REFRESH_TARGETS: frozenset[Phase] = DONE_PHASES | frozenset({
Phase.AWAITING_HUMAN,
Phase.READY_FOR_REWORK,
})
async def refresh_dependents_hook(
previous: Phase | None,
target: Phase,
item: Any,
*,
store: Any,
) -> None:
"""Fix 3 — single entry point for parent dep-frontier updates.
Before this hook existed, ``_refresh_delegation_dependents`` was
only called on the APPROVED-verdict branch of
``_finalize_review_work_item``. A child escalating to AWAITING_HUMAN
(max_review_reworks exceeded) or being CANCELLED never triggered
the refresh, so its parent's ``waiting_on_work_item_ids`` and claim
state drifted from reality. new16/app12 reproduced this: cto parent
``cdb248d8`` sat in WAITING_FOR_CHILDREN for 13+ minutes with the
claim held by an idle session, neither runnable nor orphaned.
Re-entrancy is handled inside ``refresh_dependents_for_run`` via a
ContextVar, so transitive updates (parent → RUNNING → some ancestor)
don't re-walk the same run.
"""
if previous == target:
return
if target not in _DEPENDENT_REFRESH_TARGETS:
return
run_id = str(getattr(item, "run_id", "") or "").strip()
if not run_id:
return
try:
await refresh_dependents_for_run(
store,
run_id=run_id,
source_work_item_id=str(getattr(item, "work_item_id", "") or "").strip() or None,
source_role_id=str(getattr(item, "role_id", "") or "").strip() or None,
source_cell_id=str(getattr(item, "cell_id", "") or "").strip() or None,
)
except Exception:
logger.opt(exception=True).debug(
"refresh_dependents_hook: refresh_dependents_for_run raised"
)
# ── Hook 4: clear stale role-session focus when work items terminate ─────
# Firing scope — any transition into a terminal DB phase. We deliberately
# do NOT include AWAITING_HUMAN: a human still needs to act on those, and
# the session that escalated is legitimately still "focused" on the item
# until the human resolves it.
_FOCUS_CLEAR_TARGETS: frozenset[Phase] = DONE_PHASES
async def clear_session_focus_on_terminal_hook(
previous: Phase | None,
target: Phase,
item: Any,
*,
store: Any,
) -> None:
"""Null out ``focused_work_item_id`` on any role_runtime_session still
pointing at ``item`` once it reaches a terminal phase. Also demotes
``status='blocked'`` → ``'idle'`` for those sessions so the UI and
dispatcher rehydrate pass no longer see a "CTO blocked on X" row
after X has been approved / failed / cancelled.
Why a hook and not a routine cleanup in ``complete_claim``:
``complete_claim`` only runs on the claim-release path. Work items
transitioning via review verdict, migration, or direct store writes
bypass it, leaving session.focused_work_item_id stale. new16/app13
reproduced the symptom on two leader sessions (cto / coo) that kept
``status=blocked`` + focus on APPROVED work items for ~30 minutes.
Not functionally blocking — the dispatcher keys off work_item.phase,
not session focus — but the UI mislead users and the per-tick
rehydrate pass did dead work on the stale rows.
Fix 5 PR3: when the serial-queue flag is on, after clearing focus
we reconcile the role's pending queue and clear the next valid
``queued_behind_session`` stamp so the dispatcher picks it up.
"""
if previous == target:
return
if target not in _FOCUS_CLEAR_TARGETS:
return
wid = str(getattr(item, "work_item_id", "") or "").strip()
if not wid:
return
run_id = str(getattr(item, "run_id", "") or "").strip()
if not run_id:
return
if not hasattr(store, "list_role_runtime_sessions") or not hasattr(
store, "update_delegation_role_session"
):
return
try:
sessions = await store.list_role_runtime_sessions(run_id)
except Exception:
logger.opt(exception=True).debug(
"clear_session_focus_on_terminal_hook: list_role_runtime_sessions failed "
f"run_id={run_id}"
)
return
queue_enabled = bool(getattr(store, "role_serial_queue_enabled", False))
affected_role_session_ids: set[str] = set()
item_role_session_id = str(
getattr(item, "role_runtime_session_id", "") or ""
).strip()
if item_role_session_id:
affected_role_session_ids.add(item_role_session_id)
for session in sessions:
if str(getattr(session, "focused_work_item_id", "") or "") != wid:
continue
sid = str(getattr(session, "role_session_id", "") or "").strip()
if sid:
affected_role_session_ids.add(sid)
current_status = str(getattr(session, "status", "") or "").strip().lower()
status_override = "idle" if current_status == "blocked" else None
try:
await store.update_delegation_role_session(
session.role_session_id,
focused_work_item_id="",
status=status_override,
)
except Exception:
logger.opt(exception=True).debug(
"clear_session_focus_on_terminal_hook: update_delegation_role_session "
f"failed session_id={session.role_session_id}"
)
continue
if queue_enabled and affected_role_session_ids:
# PR3 originally promoted only when the terminal item was still
# focused by the session. In practice the focus may already have
# been cleared by a different path while the queued marker remains
# on the next card. Reconcile the assigned role session either way.
await reconcile_role_serial_queues(
store,
run_id,
role_session_ids=affected_role_session_ids,
)
def _work_item_id(item: Any) -> str:
return str(getattr(item, "work_item_id", "") or "").strip()
def _work_item_role_session_id(item: Any) -> str:
return str(getattr(item, "role_runtime_session_id", "") or "").strip()
def _queued_behind_session(item: Any) -> str:
metadata = getattr(item, "metadata", {}) or {}
if not isinstance(metadata, dict):
return ""
return str(metadata.get("queued_behind_session", "") or "").strip()
def _queue_candidate(item: Any) -> bool:
if getattr(item, "phase", None) not in RUNNABLE_PHASES:
return False
claimed = str(
getattr(item, "claimed_by_role_runtime_session_id", "") or ""
).strip()
return not claimed
async def _clear_queued_marker(
item: Any,
store: Any,
*,
expected_session_id: str | None = None,
) -> bool:
metadata = dict(getattr(item, "metadata", {}) or {})
marker = str(metadata.get("queued_behind_session", "") or "").strip()
if not marker:
return False
if expected_session_id is not None and marker != str(expected_session_id or "").strip():
return False
metadata.pop("queued_behind_session", None)
item.metadata = metadata
try:
await store.save_delegation_work_item(item)
except Exception:
logger.opt(exception=True).debug(
f"clear queued_behind_session failed wid={_work_item_id(item)}"
)
return False
return True
async def _save_role_session(store: Any, session: Any) -> bool:
try:
await store.save_delegation_role_session(session)
return True
except Exception:
logger.opt(exception=True).debug(
"save_delegation_role_session failed during serial queue reconcile "
f"sid={getattr(session, 'role_session_id', '')}"
)
return False
def _active_focus_id(session: Any, work_item_by_id: dict[str, Any]) -> str:
focused = str(getattr(session, "focused_work_item_id", "") or "").strip()
if not focused:
return ""
focused_item = work_item_by_id.get(focused)
if focused_item is None:
return ""
if getattr(focused_item, "phase", None) in DONE_PHASES:
return ""
phase = getattr(focused_item, "phase", None)
if is_stale_claim_releasable(phase):
claim = str(
getattr(focused_item, "claimed_by_role_runtime_session_id", "") or ""
).strip()
if not claim:
# On a live transition the focused item retains its claim. After
# restart the startup sweep removes that dead-process claim; the
# persisted session focus must stop blocking the orphaned item (or
# a passive parent's report/review helper) from being dispatched.
return ""
return focused
async def reconcile_role_serial_queues(
store: Any,
run_id: str,
*,
role_session_ids: set[str] | list[str] | tuple[str, ...] | None = None,
) -> dict[str, Any]:
"""Repair derived serial-queue state for a run.
``pending_work_item_ids`` and ``focused_work_item_id`` are the source of
truth. ``metadata.queued_behind_session`` is only a dispatcher filter, so
it must be cleared when it no longer matches the role session's queue.
"""
result: dict[str, Any] = {
"run_id": str(run_id or "").strip(),
"cleared_markers": [],
"pruned_pending_ids": [],
"promoted_work_item_ids": [],
"cleared_focus_session_ids": [],
}
if not bool(getattr(store, "role_serial_queue_enabled", False)):
return result
rid = result["run_id"]
if not rid:
return result
required = (
"list_role_runtime_sessions",
"list_delegation_work_items",
"save_delegation_role_session",
"save_delegation_work_item",
)
if any(not hasattr(store, name) for name in required):
return result
wanted_sessions = {
str(item).strip()
for item in list(role_session_ids or [])
if str(item).strip()
}
try:
all_sessions = await store.list_role_runtime_sessions(rid)
work_items = await store.list_delegation_work_items(rid)
except Exception:
logger.opt(exception=True).debug(
f"reconcile_role_serial_queues: load failed run_id={rid}"
)
return result
sessions = list(all_sessions)
if wanted_sessions:
sessions = [
session for session in sessions
if str(getattr(session, "role_session_id", "") or "").strip()
in wanted_sessions
]
session_by_id = {
str(getattr(session, "role_session_id", "") or "").strip(): session
for session in sessions
if str(getattr(session, "role_session_id", "") or "").strip()
}
all_session_ids = {
str(getattr(session, "role_session_id", "") or "").strip()
for session in all_sessions
if str(getattr(session, "role_session_id", "") or "").strip()
}
work_item_by_id = {
_work_item_id(item): item
for item in work_items
if _work_item_id(item)
}
pending_after_by_session: dict[str, list[str]] = {}
blocker_by_session: dict[str, str] = {}
for session in sessions:
sid = str(getattr(session, "role_session_id", "") or "").strip()
if not sid:
continue
original_pending = [
str(item).strip()
for item in list(getattr(session, "pending_work_item_ids", []) or [])
if str(item).strip()
]
original_focus = str(getattr(session, "focused_work_item_id", "") or "").strip()
active_focus = _active_focus_id(session, work_item_by_id)
session_changed = False
if original_focus and not active_focus:
session.focused_work_item_id = ""
session.status = "idle"
session_changed = True
result["cleared_focus_session_ids"].append(sid)
elif active_focus and str(getattr(session, "status", "") or "").strip().lower() == "idle":
session.status = "running"
session_changed = True
clean_pending: list[str] = []
seen_pending: set[str] = set()
for pending_id in original_pending:
if pending_id in seen_pending:
result["pruned_pending_ids"].append(pending_id)
continue
seen_pending.add(pending_id)
pending_item = work_item_by_id.get(pending_id)
if pending_item is None or not _queue_candidate(pending_item):
result["pruned_pending_ids"].append(pending_id)
if pending_item is not None and await _clear_queued_marker(
pending_item,
store,
expected_session_id=sid,
):
result["cleared_markers"].append(pending_id)
continue
clean_pending.append(pending_id)
front_unqueued = ""
if not active_focus:
for candidate in work_items:
candidate_id = _work_item_id(candidate)
if not candidate_id or candidate_id in clean_pending:
continue
if _work_item_role_session_id(candidate) != sid:
continue
if not _queue_candidate(candidate):
continue
if _queued_behind_session(candidate):
continue
front_unqueued = candidate_id
break
promoted = ""
if not active_focus and not front_unqueued and clean_pending:
promoted = clean_pending.pop(0)
promoted_item = work_item_by_id.get(promoted)
if promoted_item is not None and await _clear_queued_marker(
promoted_item,
store,
expected_session_id=sid,
):
result["cleared_markers"].append(promoted)
result["promoted_work_item_ids"].append(promoted)
if clean_pending != original_pending:
session.pending_work_item_ids = clean_pending
session_changed = True
if not active_focus and not front_unqueued and not promoted:
if str(getattr(session, "status", "") or "").strip().lower() != "idle":
session.status = "idle"
session.focused_work_item_id = ""
session_changed = True
if session_changed:
await _save_role_session(store, session)
pending_after_by_session[sid] = list(clean_pending)
blocker_by_session[sid] = active_focus or front_unqueued or promoted
for item in work_items:
item_id = _work_item_id(item)
marker = _queued_behind_session(item)
if not item_id or not marker:
continue
if wanted_sessions and marker not in wanted_sessions:
continue
valid = False
if marker in all_session_ids:
pending = pending_after_by_session.get(marker)
if pending is None and not wanted_sessions:
session = session_by_id.get(marker)
pending = list(getattr(session, "pending_work_item_ids", []) or []) if session is not None else []
blocker = blocker_by_session.get(marker, "")
valid = item_id in list(pending or []) and bool(blocker) and blocker != item_id
if valid:
continue
if await _clear_queued_marker(item, store, expected_session_id=marker):
result["cleared_markers"].append(item_id)
if (
result["cleared_markers"]
or result["pruned_pending_ids"]
or result["promoted_work_item_ids"]
or result["cleared_focus_session_ids"]
):
logger.debug(f"serial queue reconciled: {result}")
if hasattr(store, "save_runtime_event"):
try:
await store.save_runtime_event(
rid,
"serial_queue_reconciled",
result,
)
except Exception:
logger.opt(exception=True).debug(
f"serial_queue_reconciled event emit failed run_id={rid}"
)
if result["promoted_work_item_ids"]:
for cb in list(_DISPATCHER_WAKE_HOOKS):
try:
cb()
except Exception:
logger.opt(exception=True).debug("dispatcher wake callback raised")
return result
async def _promote_next_pending_for_session(session: Any, store: Any) -> None:
"""Compatibility helper that reconciles one session's serial queue.
The reconciler prunes dead entries before promoting the next runnable
item, so this wrapper is safer than blindly popping the FIFO head.
"""
sid = str(getattr(session, "role_session_id", "") or "").strip()
if not sid:
return
run_id = str(getattr(session, "run_id", "") or "").strip()
if not run_id:
return
await reconcile_role_serial_queues(store, run_id, role_session_ids={sid})
# ── Hook 5 (Fix 5 PR3): enqueue runnable work for busy sessions ──────────
_RUNNABLE_ENQUEUE_TARGETS: frozenset[Phase] = RUNNABLE_PHASES
async def enqueue_session_work_on_runnable_hook(
previous: Phase | None,
target: Phase,
item: Any,
*,
store: Any,
) -> None:
"""When a work item becomes runnable for a role whose session is
already focused on something else, append it to the session's pending
queue and stamp ``metadata.queued_behind_session`` so the dispatcher
skips it until its turn.
Gated by ``store.role_serial_queue_enabled``. The flag remains available
for explicit compatibility tests, but company mode enables serial role
queues by default.
Design notes:
* The enqueue is idempotent. If the work item is already in the
queue, ``enqueue_pending_work_item`` returns False and this hook
skips the metadata stamp. That handles the case where the hook
fires twice on the same item (e.g. READY → RUNNING → READY_FOR_REWORK
cycling) without double-enqueuing.
* We deliberately leave the work item's phase alone — it stays
READY/READY_FOR_REWORK so the dispatcher still considers it, and
the claim filter (PR3.4) uses the ``queued_behind_session`` stamp
to decide whether to skip.
* No work is enqueued when the session's own focus IS this work item
(transient race during a claim — the session just grabbed the item
and its phase transitioned to RUNNING then back). That's handled by
the focus check: if ``session.focused_work_item_id == wid`` the
session is NOT busy with a different item, so we don't enqueue.
"""
if previous == target:
return
if not bool(getattr(store, "role_serial_queue_enabled", False)):
return
if target not in _RUNNABLE_ENQUEUE_TARGETS:
return
wid = str(getattr(item, "work_item_id", "") or "").strip()
if not wid:
return
sid = str(getattr(item, "role_runtime_session_id", "") or "").strip()
if not sid:
# No session stamped on the work item yet — the engine's bootstrap
# will stamp it before dispatch. Skip; we'll see the next
# transition once the session id is available.
return
# Is the session busy with a different work item?
try:
session = await store.get_delegation_role_session(sid)
except Exception:
logger.opt(exception=True).debug(
f"enqueue_session_work_on_runnable_hook: get_delegation_role_session "
f"failed sid={sid}"
)
return
if session is None:
return
focused = str(getattr(session, "focused_work_item_id", "") or "").strip()
if not focused or focused == wid:
# Session is idle or already holding this exact work item — let
# the normal claim path proceed without queueing.
return
# Append to queue, stamp metadata so the claim filter skips this wid.
try:
enqueued = await store.enqueue_pending_work_item(sid, wid)
except Exception:
logger.opt(exception=True).debug(
f"enqueue_session_work_on_runnable_hook: enqueue failed "
f"sid={sid} wid={wid}"
)
return
if not enqueued:
return # already in the queue — nothing more to do
metadata = dict(getattr(item, "metadata", {}) or {})
if metadata.get("queued_behind_session") == sid:
return
metadata["queued_behind_session"] = sid
try:
await store.update_delegation_work_item(
wid, metadata_updates={"queued_behind_session": sid}
)
except Exception:
logger.opt(exception=True).debug(
f"enqueue_session_work_on_runnable_hook: update_delegation_work_item "
f"failed wid={wid}"
)
# Fix 5 PR7 observability: emit a runtime event when the queue
# crosses the attention threshold. Fires at the *crossing* so we
# don't spam on every subsequent enqueue — dedup handled by checking
# that the queue length equals the threshold exactly after this
# enqueue (previous state was threshold-1 or lower, now we're at
# threshold). ``STUCK_QUEUE_DEPTH_THRESHOLD`` is conservative;
# operators can watch for the event and investigate before a true
# pile-up develops.
try:
refreshed = await store.get_delegation_role_session(sid)
except Exception:
refreshed = None
if refreshed is not None:
depth = len(list(getattr(refreshed, "pending_work_item_ids", []) or []))
if depth == STUCK_QUEUE_DEPTH_THRESHOLD and hasattr(store, "save_runtime_event"):
try:
await store.save_runtime_event(
sid,
"stuck_session_queue_depth",
{
"role_session_id": sid,
"role_id": str(getattr(refreshed, "role_id", "") or ""),
"run_id": str(getattr(refreshed, "run_id", "") or ""),
"focused_work_item_id": str(
getattr(refreshed, "focused_work_item_id", "") or ""
),
"queue_depth": depth,
"threshold": STUCK_QUEUE_DEPTH_THRESHOLD,
"triggered_by_work_item_id": wid,
},
)
except Exception:
logger.opt(exception=True).debug(
f"PR7 stuck-queue event emit failed sid={sid}"
)
# ── Fix 5 PR7 thresholds ─────────────────────────────────────────────────
# Queue-depth threshold: when a role's pending queue first reaches this,
# we emit ``stuck_session_queue_depth``. Chosen conservatively — with the
# serial queue on, a handful of pending items is normal during a delegation
# wave; 5 in flight to one role means upstream is producing faster than
# the role can drain and ops should look.
STUCK_QUEUE_DEPTH_THRESHOLD = 5
# Stuck-focus threshold (minutes): when a session has held the same
# ``focused_work_item_id`` for longer than this without a phase transition,
# ``check_stuck_focused_sessions`` emits a ``stuck_session_focused`` event.
# This is a poll-style check; call it periodically from a dispatcher tick
# or from an ops CLI (not auto-fired by the phase hooks).
STUCK_FOCUS_MINUTES = 10
async def check_stuck_focused_sessions(
store: Any,
*,
run_id: str | None = None,
threshold_minutes: int = STUCK_FOCUS_MINUTES,
) -> list[dict[str, Any]]:
"""Scan role_runtime_sessions whose ``focused_work_item_id`` has been
set for longer than ``threshold_minutes`` and emit one
``stuck_session_focused`` runtime event per offender.
Returns a list of the emitted event payloads for ops visibility.
Safe to call on every tick — events are cheap inserts and the dedup
is done by the consumer (downstream dashboards filter by last-seen-time).
"""
from datetime import datetime, timedelta
if not hasattr(store, "list_role_runtime_sessions"):
return []
if run_id:
sessions = await store.list_role_runtime_sessions(run_id)
else:
# Walk every known run. The store exposes per-run listing only,
# so gather run_ids from delegation_runs first.
if not hasattr(store, "list_delegation_runs"):
return []
try:
runs = await store.list_delegation_runs()
except Exception:
return []
sessions = []
for run in runs:
try:
sessions.extend(await store.list_role_runtime_sessions(run.run_id))
except Exception:
continue
threshold = timedelta(minutes=max(0, int(threshold_minutes)))
now = datetime.now()
emitted: list[dict[str, Any]] = []
for session in sessions:
focused = str(getattr(session, "focused_work_item_id", "") or "").strip()
if not focused:
continue
updated_at = getattr(session, "updated_at", None)
if not isinstance(updated_at, datetime):
continue
if now - updated_at < threshold:
continue
payload = {
"role_session_id": session.role_session_id,
"role_id": getattr(session, "role_id", ""),
"run_id": getattr(session, "run_id", ""),
"focused_work_item_id": focused,
"focused_for_minutes": int((now - updated_at).total_seconds() // 60),
"threshold_minutes": int(threshold_minutes),
"queue_depth": len(list(getattr(session, "pending_work_item_ids", []) or [])),
}
if hasattr(store, "save_runtime_event"):
try:
await store.save_runtime_event(
session.role_session_id,
"stuck_session_focused",
payload,
)
except Exception:
logger.opt(exception=True).debug(
f"PR7 stuck-focus event emit failed "
f"sid={session.role_session_id}"
)
emitted.append(payload)
return emitted
# ── Module-level registration ────────────────────────────────────────────
register_phase_transition_hook(sync_task_status_hook)
register_phase_transition_hook(signal_dispatcher_hook)
register_phase_transition_hook(refresh_dependents_hook)
register_phase_transition_hook(clear_session_focus_on_terminal_hook)
# Fix 5 PR3 — gated internally on ``store.role_serial_queue_enabled``,
# always-registered so flipping the flag at runtime takes effect without
# re-wiring the hook list.
register_phase_transition_hook(enqueue_session_work_on_runnable_hook)