"""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)