"""Drain thread for terminal(notify_on_complete=true) agent wakeup.

The hermes-agent ``tools.process_registry.ProcessRegistry`` exposes a thread-safe
``completion_queue`` (a ``queue.Queue``) that any background process pushes onto
when it exits or matches a ``watch_patterns`` rule. In the CLI and in the
gateway adapter this queue is drained by the host's main loop; in WebUI the
queue was never read, so the agent never woke up from a ``notify_on_complete``
finish. This module restores that behavior.

The drain thread:
    1. blocks on ``completion_queue.get()`` in a worker thread,
    2. looks up the WebUI session_id from ``PROCESS_SESSION_INDEX`` (keyed on
       the per-process ``session_key`` env var captured at spawn time),
    3. formats a synthetic ``[IMPORTANT: ...]`` wakeup prompt, identical in
       intent to ``cli._format_process_notification`` and
       ``gateway.run._format_gateway_process_notification`` so the agent sees
       the same payload regardless of host,
    4. emits a canonical ``bg_task_complete`` SSE event (plus a temporary
       ``process_complete`` alias for the migration window) on the active
       stream(s) for that session (DEMOTED to pure live-view — an open tab
       streams the turn live), records a server-side marker in
       ``PENDING_BG_TASK_COMPLETIONS``,
       and — Option Z PIVOT — starts the agent wakeup turn **directly
       server-side** when the session is idle (``_start_server_side_wakeup_turn``
       → ``routes.start_session_turn``). This needs NO browser round-trip, so
       the closed-tab case works exactly like CLI / Telegram / gateway
       self-wake. When a turn is already active the wakeup is NOT started here;
       the ``PENDING_BG_TASK_COMPLETIONS`` marker is left for PR #2279's
       next-turn drain (``api/streaming._drain_webui_process_notifications``).

The marker is *not* required for delivery — it's a telemetry-style flag the
turn handler can read to know "this stream is a process_complete wakeup, not a
human-typed prompt". It also lets the PR #2279 next-turn drain deliver the
wakeup when a turn was active at completion time; the marker drains harmlessly
on the next turn for the session.

Watch-pattern events share the same queue but produce a different SSE payload;
this module routes them to the same listener so the frontend's single
``process_complete`` handler can re-POST either flavor verbatim.
"""

from __future__ import annotations

import logging
import queue
import threading
import time
import uuid
from typing import Any, Optional

logger = logging.getLogger(__name__)

_DRAIN_THREAD: Optional[threading.Thread] = None
_DRAIN_STOP = threading.Event()

_REAPER_THREAD: Optional[threading.Thread] = None
_REAPER_STOP = threading.Event()
_REAPER_INTERVAL_SECS = 60.0

# T3: per-session coalesce gate for the public bg_task_complete SSE emit.
# The server-side wakeup path remains immediate; only the browser-observation
# frame is throttled so a burst of background task completions does not flood an
# open tab. The first emit for a session fires immediately, then any further
# emits inside the 1s window are payload-replaced and flushed after 1s of quiet.
_EMIT_COALESCE_WINDOW_SECS = 1.0
_EMIT_COALESCE_LOCK = threading.Lock()
_LAST_EMIT_TS: dict[str, float] = {}
_PENDING_EMIT_PAYLOADS: dict[str, dict] = {}
_PENDING_EMIT_TIMERS: dict[str, threading.Timer] = {}


# ── Persistent per-session SSE channel (Option X) ──────────────────────────
# SESSION_CHANNELS maps WebUI session_id -> SessionChannel. Each channel owns
# zero or more queue.Queue subscribers (one per active EventSource tab) and
# is collected by ``_reaper_loop`` after the last subscriber drops + a grace
# period, or after the session has been idle for SESSION_CHANNEL_IDLE_TTL_SECS.
#
# Why a sibling registry to STREAMS:
#   STREAMS is keyed on stream_id (one per agent turn) and is torn down by
#   /api/chat/stream's `finally` when the turn ends. process_complete events
#   from background processes that exit BETWEEN turns therefore have no live
#   STREAMS channel to ride. SESSION_CHANNELS is keyed on session_id, lives
#   across turns, and gives the frontend a stable subscription that survives
#   stream_end / cancel / reconnect.
SESSION_CHANNELS: dict[str, "SessionChannel"] = {}
SESSION_CHANNELS_LOCK = threading.Lock()


class SessionChannel:
    """A long-lived multi-subscriber SSE channel for one WebUI session.

    Subscribers are ``queue.Queue`` instances owned by the SSE route
    handler — one per active EventSource (tab). ``emit`` broadcasts to every
    live subscriber; subscribers whose buffer is full silently drop the
    event (the tab will reconnect on disconnect and the SSE-level disconnect
    detection will tear it down).

    Lifecycle:
      - Created on demand by ``get_or_create_session_channel`` when the first
        tab subscribes.
      - ``subscribe`` / ``unsubscribe`` are refcount-style: zero subscribers
        does NOT immediately collect the channel; the reaper waits a 60s
        grace so a quick navigation away/back doesn't churn the registry.
      - The reaper collects the channel when subscribers stay empty past the
        grace period, OR when subscribers are empty AND ``created_at`` is
        older than SESSION_CHANNEL_IDLE_TTL_SECS (zombie cap — applies only
        when nobody is subscribed; a live subscriber keeps the channel even
        past the idle TTL).
    """

    def __init__(self, session_id: str):
        self.session_id = session_id
        self._lock = threading.Lock()
        self._subscribers: list[queue.Queue] = []
        now = time.time()
        self.created_at = now
        self.last_event_at = now
        self.last_subscriber_drop_at: float | None = None

    def subscribe(self, maxsize: int = 16) -> queue.Queue:
        q: queue.Queue = queue.Queue(maxsize=maxsize)
        with self._lock:
            self._subscribers.append(q)
            # Cancel any pending subscribers-empty grace timer.
            self.last_subscriber_drop_at = None
        return q

    def unsubscribe(self, q: queue.Queue) -> None:
        with self._lock:
            try:
                self._subscribers.remove(q)
            except ValueError:
                pass
            if not self._subscribers:
                self.last_subscriber_drop_at = time.time()

    def subscriber_count(self) -> int:
        with self._lock:
            return len(self._subscribers)

    def emit(self, event: str, data: Any) -> int:
        """Broadcast (event, data) to all live subscribers. Returns delivered count."""
        delivered = 0
        with self._lock:
            subs = list(self._subscribers)
            self.last_event_at = time.time()
        for q in subs:
            try:
                q.put_nowait((event, data))
                delivered += 1
            except queue.Full:
                # Slow tab: drop this event for that tab. SSE-level disconnect
                # detection will eventually tear the connection down and the
                # browser will reconnect, replaying the live stream from
                # whatever fires next. process_complete is intrinsically
                # idempotent (frontend dedupes by ``(session_id, event_id)``
                # using a small ring-buffer in static/messages.js — see the
                # bg_task_complete consumer-side dedupe introduced in PR #2971).
                logger.debug("SessionChannel emit: subscriber buffer full, dropping")
            except Exception:
                logger.debug("SessionChannel emit failed", exc_info=True)
        return delivered

    def reaper_should_collect(self, now: float) -> bool:
        """True when the reaper should remove this channel.

        Two collection conditions (per Option X spec):
          1. Subscribers empty AND last_subscriber_drop_at is older than
             SESSION_CHANNEL_SUBSCRIBER_GRACE_SECS (normal teardown).
          2. created_at older than SESSION_CHANNEL_IDLE_TTL_SECS AND
             subscribers empty (zombie cap — survived too long).
        """
        from api import config as _cfg

        with self._lock:
            sub_count = len(self._subscribers)
            drop_at = self.last_subscriber_drop_at
            created_at = self.created_at

        if sub_count > 0:
            # Live subscriber — never collect, even past idle TTL (a tab is
            # genuinely listening). The browser will close on its own.
            return False
        # No subscribers — check grace period.
        grace = float(getattr(_cfg, "SESSION_CHANNEL_SUBSCRIBER_GRACE_SECS", 60))
        if drop_at is not None and (now - drop_at) >= grace:
            return True
        # Hard cap on lifetime (even if subscribers oscillated): if created
        # long ago AND nobody's subscribed right now, sweep.
        ttl = float(getattr(_cfg, "SESSION_CHANNEL_IDLE_TTL_SECS", 14400))
        if (now - created_at) >= ttl:
            return True
        return False


def get_or_create_session_channel(session_id: str) -> SessionChannel:
    """Return the channel for ``session_id``, creating it on first access."""
    with SESSION_CHANNELS_LOCK:
        ch = SESSION_CHANNELS.get(session_id)
        if ch is None:
            ch = SessionChannel(session_id)
            SESSION_CHANNELS[session_id] = ch
        return ch


def get_session_channel(session_id: str) -> Optional[SessionChannel]:
    """Return an existing channel or None — does NOT auto-create."""
    with SESSION_CHANNELS_LOCK:
        return SESSION_CHANNELS.get(session_id)


def subscribe_to_session_channel(
    session_id: str, maxsize: int = 16
) -> tuple["SessionChannel", "queue.Queue"]:
    """Atomically get-or-create the channel for ``session_id`` AND register a
    subscriber on it, both under ``SESSION_CHANNELS_LOCK``.

    Closes a TOCTOU race flagged on PR #2971: callers that did
    ``ch = get_or_create_session_channel(sid); q = ch.subscribe()`` released
    ``SESSION_CHANNELS_LOCK`` between the two steps. The reaper acquires that
    same lock and evaluates+collects channels entirely within it
    (``_reaper_loop``), so a previously-idle channel with 0 subscribers past
    the grace/TTL window could be collected in the gap — leaving the SSE
    handler subscribed to a channel no longer in ``SESSION_CHANNELS``. Future
    ``bg_task_complete`` emits resolve the session via ``get_session_channel``
    and reach a different (or absent) channel, so the orphaned subscriber's
    queue never fills: keepalives keep flowing, ``onerror`` never fires, no
    auto-reconnect, and only a manual refresh recovers.

    Holding ``SESSION_CHANNELS_LOCK`` across both the get-or-create and the
    ``subscribe`` makes the two steps indivisible w.r.t. the reaper: the reaper
    cannot run its critical section concurrently, and the next time it does the
    channel already has ``sub_count >= 1`` so ``reaper_should_collect`` refuses
    to collect it.

    Lock order is ``SESSION_CHANNELS_LOCK`` → ``SessionChannel._lock`` (taken
    inside ``subscribe``), identical to the order the reaper uses
    (``SESSION_CHANNELS_LOCK`` → ``reaper_should_collect`` → ``_lock``), so no
    new lock-ordering hazard is introduced. ``emit`` only takes ``_lock``.

    Returns ``(channel, queue)``. The caller still owns the subscriber slot and
    MUST ``channel.unsubscribe(queue)`` on every exit path.
    """
    with SESSION_CHANNELS_LOCK:
        ch = SESSION_CHANNELS.get(session_id)
        if ch is None:
            ch = SessionChannel(session_id)
            SESSION_CHANNELS[session_id] = ch
        q = ch.subscribe(maxsize=maxsize)
        return ch, q


def active_stream_id_for_session(session_id: str) -> Optional[str]:
    """Return the stream_id of the live run for *session_id*, or None.

    Used by the per-session SSE handler's on-subscribe recovery: the
    ``server_turn_started`` fan-out in ``routes.start_session_turn`` is a
    fire-and-forget broadcast with NO replay buffer (SessionChannel.emit
    drops to whoever is subscribed *at that instant*). A tab whose
    ``/api/session/stream`` EventSource is momentarily absent at the emit
    instant — a transient SSE drop, a reverse-proxy idle-timeout, or browser
    connection-pool starvation — misses the frame permanently and the
    server-initiated wakeup turn never renders live (the user must hard-
    refresh). The server-side wakeup itself still ran and persisted; only the
    live-view was lost. The handler replays a synthetic ``server_turn_started``
    to a freshly-subscribed tab using this lookup so the open tab self-heals.

    Keys on ACTIVE_RUNS (worker-lifecycle registry) — the same source
    ``_session_has_active_turn`` / ``_emit_to_session_streams`` already trust
    to map a stream back to its owning session. Returns the first matching
    stream_id (a session has at most one live run; cancel/reconnect can
    briefly hold two — either is a valid attach target, the frontend dedupes
    by stream_id).
    """
    from api import config as _cfg

    try:
        with _cfg.ACTIVE_RUNS_LOCK:
            for _stream_id, meta in (_cfg.ACTIVE_RUNS or {}).items():
                if isinstance(meta, dict) and meta.get("session_id") == session_id:
                    return str(_stream_id)
    except Exception:
        logger.debug(
            "active_stream_id_for_session lookup failed for %s",
            session_id,
            exc_info=True,
        )
    return None


def _reaper_loop() -> None:
    logger.info("SessionChannel reaper thread started")
    while not _REAPER_STOP.is_set():
        try:
            now = time.time()
            collected: list[str] = []
            with SESSION_CHANNELS_LOCK:
                for sid, ch in list(SESSION_CHANNELS.items()):
                    if ch.reaper_should_collect(now):
                        SESSION_CHANNELS.pop(sid, None)
                        collected.append(sid)
            if collected:
                # Prune the per-session coalesce timestamp map for any collected
                # session so _LAST_EMIT_TS does not grow one permanent entry per
                # session that ever fired a bg task (greptile flag). The pending
                # payload/timer maps self-clean when their timers fire, but
                # _LAST_EMIT_TS is only ever written, never deleted — sweep it
                # here alongside the channel it belongs to. A session that fires
                # again after collection simply re-seeds its entry (first emit in
                # the new window fires immediately, which is correct).
                with _EMIT_COALESCE_LOCK:
                    for sid in collected:
                        _LAST_EMIT_TS.pop(sid, None)
                logger.debug("SessionChannel reaper collected: %s", collected)
        except Exception:
            logger.warning("SessionChannel reaper iteration failed", exc_info=True)
        # Wait but wake up promptly on stop.
        if _REAPER_STOP.wait(_REAPER_INTERVAL_SECS):
            break


def start_session_channel_reaper() -> bool:
    """Start the SessionChannel reaper thread. Idempotent; returns True on first start."""
    global _REAPER_THREAD
    if _REAPER_THREAD is not None and _REAPER_THREAD.is_alive():
        return False
    _REAPER_STOP.clear()
    _REAPER_THREAD = threading.Thread(
        target=_reaper_loop,
        name="hermes-webui-session-channel-reaper",
        daemon=True,
    )
    _REAPER_THREAD.start()
    return True


def stop_session_channel_reaper(timeout: float = 2.0) -> None:
    _REAPER_STOP.set()
    th = _REAPER_THREAD
    if th is not None and th.is_alive():
        th.join(timeout=timeout)


def _truncate(text: str, limit: int) -> str:
    if text is None:
        return ""
    s = str(text)
    if len(s) <= limit:
        return s
    return s[:limit] + "\n…(truncated)"


def format_wakeup_prompt(evt: dict) -> str:
    """Build the synthetic [IMPORTANT: …] message the agent will see.

    Mirrors ``cli._format_process_notification`` so wakeup payloads look the
    same in CLI and WebUI sessions.
    """
    evt_type = evt.get("type", "completion")
    sid = evt.get("session_id", "unknown")
    cmd = evt.get("command", "unknown")
    if evt_type == "watch_disabled":
        return f"[IMPORTANT: {evt.get('message', '')}]"
    if evt_type == "watch_match":
        pat = evt.get("pattern", "?")
        out = _truncate(evt.get("output", ""), 4000)
        sup = evt.get("suppressed", 0)
        body = (
            f"[IMPORTANT: Background process {sid} matched watch pattern \"{pat}\".\n"
            f"Command: {cmd}\n"
            f"Matched output:\n{out}"
        )
        if sup:
            body += f"\n({sup} earlier matches were suppressed by rate limit)"
        return body + "]"
    # Default: completion event
    exit_code = evt.get("exit_code", "?")
    out = _truncate(evt.get("output", ""), 4000)
    return (
        f"[IMPORTANT: Background process {sid} completed (exit_code={exit_code}).\n"
        f"Command: {cmd}\n"
        f"Output:\n{out}]"
    )


def _build_payload(evt: dict, session_id: str) -> dict:
    """Build the SSE data payload.

    Shape per maintainer decision on PR #2242 (R2 §Q1):
        ``{session_id, task_id, completed_at, summary?, event_id}``
    Minimal by design — consumers re-fetch task detail by ``task_id`` if
    they need ``command`` / ``exit_code`` / ``stdout_preview`` etc.

    - ``task_id``: the background process id (registry uuid). Stable across
      the process's lifetime; was previously surfaced as ``process_id``.
    - ``completed_at``: float wall-clock seconds; was ``emitted_at``.
    - ``summary``: optional one-liner derived from the completion event when
      available (e.g. ``[IMPORTANT: …]`` synthetic body's first line); omitted
      otherwise to honour "keep the payload minimal".
    - ``event_id``: server-generated uuid hex — per-emit, so a re-emit for the
      same ``task_id`` (shouldn't happen today, but the spec is forward-looking)
      still produces a distinct id. The cross-A/B dedupe stays keyed on
      ``task_id`` (it prevents double-emit at the source, which is the right
      layer); the WebUI consumer-side ring buffer (PR (b)) keys on
      ``(session_id, event_id)`` to dedupe across reconnects.
    """
    # ProcessRegistry completion events use the field name ``session_id`` for
    # the process id. Alias it locally before exposing it as payload ``task_id``
    # to avoid confusing that wire-format name with the WebUI session id.
    process_id = str(evt.get("session_id") or "")
    payload: dict[str, Any] = {
        "session_id": str(session_id),
        "task_id": process_id,
        "completed_at": time.time(),
        "event_id": uuid.uuid4().hex,
    }
    # Best-effort optional summary: the first non-empty line of the synthetic
    # wakeup body, trimmed. Omitted entirely when nothing useful is available.
    try:
        wakeup_body = format_wakeup_prompt(evt)
        if wakeup_body:
            # Strip leading "[IMPORTANT: " marker noise — take the first
            # informative line, cap length.
            first_line = next(
                (
                    ln.strip().lstrip("[").rstrip("]").strip()
                    for ln in wakeup_body.splitlines()
                    if ln.strip()
                ),
                "",
            )
            if first_line:
                payload["summary"] = _truncate(first_line, 200)
    except Exception:
        # Summary is optional; never let its derivation block the emit.
        logger.debug("summary derivation failed", exc_info=True)
    return payload


def _emit_to_session_streams(session_id: str, event: str, data: dict) -> int:
    """Push (event, data) to every active SSE channel for *session_id*.

    Streams in WebUI are keyed by ``stream_id`` (not session_id) — a single
    session can have at most one active stream at a time, but cancel/reconnect
    flows can briefly hold two. We push to every channel whose tracked
    ``session_id`` matches; the channel implementation broadcasts to all live
    subscribers and buffers when offline.
    """
    from api import config as _cfg

    emitted = 0
    # Snapshot ACTIVE_RUNS under its lock before STREAMS_LOCK so owner_sid
    # lookups are consistent without nesting the two independent locks.
    if hasattr(_cfg, "ACTIVE_RUNS") and hasattr(_cfg, "ACTIVE_RUNS_LOCK"):
        with _cfg.ACTIVE_RUNS_LOCK:
            active_runs_snapshot: dict = dict(_cfg.ACTIVE_RUNS)
    elif hasattr(_cfg, "ACTIVE_RUNS"):
        active_runs_snapshot = dict(_cfg.ACTIVE_RUNS)
    else:
        active_runs_snapshot = {}
    with _cfg.STREAMS_LOCK:
        items = list(_cfg.STREAMS.items())
    for stream_id, channel in items:
        meta = active_runs_snapshot.get(stream_id)
        owner_sid = (meta or {}).get("session_id") if isinstance(meta, dict) else None
        # Copilot review #3: skip non-matching and owner-unknown STREAMS
        # channels. Cross-turn delivery is handled by SESSION_CHANNELS below.
        if owner_sid != session_id:
            continue
        try:
            channel.put_nowait((event, data))
            emitted += 1
        except Exception:
            logger.debug("process_complete emit failed for stream %s", stream_id, exc_info=True)
    # Option X: also emit to the persistent per-session SSE channel. This is
    # the path that survives between turns (when STREAMS is torn down). We
    # keep the STREAMS emit above as defense-in-depth — if a turn IS active
    # the frontend dedupes by process_id, so a double-delivery is harmless.
    ch = get_session_channel(session_id)
    if ch is not None:
        try:
            delivered = ch.emit(event, data)
            emitted += delivered
        except Exception:
            logger.debug("SessionChannel emit failed for session %s", session_id, exc_info=True)
    return emitted


def _emit_bg_task_complete_events_now(session_id: str, payload: dict) -> int:
    """Emit the canonical bg_task_complete event and temporary legacy alias."""
    # T1 emit rename: the canonical event name is now ``bg_task_complete``
    # (per maintainer decision on PR #2242). Until PR (b) lands the new
    # consumer-side listener, we ALSO emit the legacy ``process_complete``
    # name so any in-flight WebUI build (subscribed to the old listener)
    # still receives the wakeup. PR (b) will:
    #   1. Add `bg_task_complete` listeners on the WebUI side.
    #   2. Remove this dual-emit shim (drop the legacy alias).
    # Both emits carry the SAME trimmed payload + the SAME event_id, so a
    # consumer that ever sees both can dedupe by ``event_id``.
    #
    # Greptile P2: hand each emit its OWN shallow copy of the payload. The
    # same dict object would otherwise be referenced by every subscriber queue
    # across STREAMS and SESSION_CHANNELS for BOTH event names; a downstream
    # consumer that mutates it in place would silently corrupt all other
    # concurrent consumers' views. Shallow copies are sufficient — the payload
    # is a flat trimmed dict of scalars.
    return (
        _emit_to_session_streams(session_id, "bg_task_complete", dict(payload))
        + _emit_to_session_streams(session_id, "process_complete", dict(payload))
    )


def _flush_coalesced_bg_task_complete(session_id: str) -> None:
    """Timer callback: flush the latest pending payload for one session."""
    payload: dict | None = None
    with _EMIT_COALESCE_LOCK:
        payload = _PENDING_EMIT_PAYLOADS.pop(session_id, None)
        _PENDING_EMIT_TIMERS.pop(session_id, None)
        if payload is not None:
            _LAST_EMIT_TS[session_id] = time.time()
    if payload is None:
        return
    try:
        _emit_bg_task_complete_events_now(session_id, payload)
    except Exception:
        logger.debug(
            "coalesced bg_task_complete flush failed for session %s",
            session_id,
            exc_info=True,
        )


def _emit_bg_task_complete_events_coalesced(session_id: str, payload: dict) -> int:
    """Per-session 1s coalesce gate around the bg_task_complete dual emit.

    The first payload for a session emits immediately. If another payload for
    that session arrives within ``_EMIT_COALESCE_WINDOW_SECS`` of the last emit,
    replace the pending payload and reset the quiet timer. The deferred flush
    therefore uses the latest payload from a burst.
    """
    if not session_id:
        return 0
    should_emit_now = False
    now = time.time()
    with _EMIT_COALESCE_LOCK:
        last = _LAST_EMIT_TS.get(session_id)
        has_pending = session_id in _PENDING_EMIT_TIMERS
        if last is None or (now - last) >= _EMIT_COALESCE_WINDOW_SECS:
            _LAST_EMIT_TS[session_id] = now
            should_emit_now = True
            if has_pending:
                _PENDING_EMIT_PAYLOADS.pop(session_id, None)
                old_timer = _PENDING_EMIT_TIMERS.pop(session_id, None)
                if old_timer is not None:
                    try:
                        old_timer.cancel()
                    except Exception:
                        logger.debug(
                            "coalesced bg_task_complete timer cancel failed for session %s",
                            session_id,
                            exc_info=True,
                        )
        else:
            _PENDING_EMIT_PAYLOADS[session_id] = payload

        if not should_emit_now:
            old_timer = _PENDING_EMIT_TIMERS.get(session_id)
            if old_timer is not None:
                try:
                    old_timer.cancel()
                except Exception:
                    logger.debug(
                        "coalesced bg_task_complete timer cancel failed for session %s",
                        session_id,
                        exc_info=True,
                    )
            timer = threading.Timer(
                _EMIT_COALESCE_WINDOW_SECS,
                _flush_coalesced_bg_task_complete,
                args=(session_id,),
            )
            timer.daemon = True
            _PENDING_EMIT_TIMERS[session_id] = timer
            timer.start()

    if should_emit_now:
        return _emit_bg_task_complete_events_now(session_id, payload)
    return 0


# ── Coupling contract: agent ProcessRegistry cross-A/B dedupe key ──────────
# This WebUI drain (B) and the merged upstream PR #2279 next-turn drain (A)
# dedupe a process_id against a SINGLE shared key inside the agent's
# ``tools.process_registry.ProcessRegistry``:
#
#   * READ  side: the PUBLIC ``is_completion_consumed(process_id)`` method
#     (used in ``_process_one`` above) — stable public API.
#   * WRITE side: there is NO public ``mark_completion_consumed`` upstream, so
#     B must reach into the registry's private ``_completion_consumed`` set
#     (guarded by its private ``_lock``) to set the shared marker A reads.
#
# That private WRITE coupling is what Copilot review #2242 comment #4 flagged.
# The long-term fix is an upstream PUBLIC ``mark_completion_consumed`` — see
# the test ``test_registry_completion_consumed_contract`` which fails CI LOUD
# the moment ``_completion_consumed`` / ``_lock`` / ``is_completion_consumed``
# is renamed or retyped upstream, instead of a future rename silently
# reintroducing the double-wakeup bug. ``_mark_registry_completion_consumed``
# narrows the exception handling so a rename is logged at ERROR (visible in
# errors.log + monitoring) rather than swallowed by a broad ``except`` at
# DEBUG. ImportError stays best-effort (the registry is legitimately absent in
# non-agent unit-test contexts; this module's own locked
# ``BG_TASK_COMPLETE_EVENTS_SEEN`` gate still dedupes B's own duplicates there).
_REGISTRY_CONSUMED_CONTRACT = ("_lock", "_completion_consumed", "is_completion_consumed")


def _mark_registry_completion_consumed(process_id: str) -> None:
    """Set the shared cross-A/B dedupe marker on the agent ProcessRegistry.

    Couples to ``ProcessRegistry`` privates (``_lock`` /
    ``_completion_consumed``) because no public ``mark_completion_consumed``
    exists upstream (the read side uses the public
    ``is_completion_consumed``). A future upstream rename must FAIL LOUD, not
    silently reintroduce the double-wakeup: an ``AttributeError`` / ``TypeError``
    from the private access is logged at ERROR with a contract-violation
    message (and ``test_registry_completion_consumed_contract`` breaks CI at
    test time). Only ``ImportError`` is treated as best-effort/expected (the
    registry is absent in pure unit-test contexts).
    """
    try:
        from tools.process_registry import process_registry as _pr
    except ImportError:
        # Agent registry not importable (e.g. isolated unit test) — B's own
        # locked BG_TASK_COMPLETE_EVENTS_SEEN gate still prevents this module's
        # duplicates; cross-A/B dedupe is moot when A isn't running either.
        logger.debug(
            "tools.process_registry not importable; skipping shared "
            "completion-consumed marker (best-effort, expected off-agent)",
            exc_info=True,
        )
        return
    try:
        lock = _pr._lock
        consumed = _pr._completion_consumed
    except AttributeError:
        logger.error(
            "ProcessRegistry coupling contract VIOLATED: expected private "
            "attrs %s for cross-A/B wakeup dedupe are missing — an upstream "
            "rename has broken the shared marker; process_complete wakeups may "
            "now double-fire. A public mark_completion_consumed() upstream is "
            "the durable fix (Copilot #2242 review #4).",
            _REGISTRY_CONSUMED_CONTRACT,
            exc_info=True,
        )
        return
    try:
        with lock:
            consumed.add(process_id)
    except (AttributeError, TypeError):
        logger.error(
            "ProcessRegistry coupling contract VIOLATED: _lock/"
            "_completion_consumed changed shape (not a Lock / not a set) — "
            "cross-A/B wakeup dedupe is broken; wakeups may double-fire. "
            "Upstream public mark_completion_consumed() is the durable fix "
            "(Copilot #2242 review #4).",
            exc_info=True,
        )


# ── xsession wakeup misroute defense-in-depth (Option 3) ───────────────────
# Option 1 (api/streaming._set_turn_session_identity) is the ROOT fix: it binds
# the per-turn session identity to a contextvar so a notify_on_complete spawn
# can no longer capture a concurrent turn's process-global env. Option 3 is an
# INDEPENDENT completion-time safety net at the wakeup-routing layer: even if
# some future regression reintroduces a capture race, a positively-detected
# mismatch must not wake the wrong session.
#
# The proc->owner link the WebUI drain trusts is ProcessSession.session_key,
# which the terminal tool captured from the (historically racy) env at spawn.
# An env-IMMUNE spawn-time owner would be the authoritative cross-check, but
# adding such a field to the core ProcessSession is out of scope for this
# WebUI-only change. So this resolver is forward-compatible by DUCK-TYPING:
# if a future core grows an env-immune spawn-owner attribute (any of the
# names below) AND it positively disagrees with the session_key-resolved
# target, re-route to the env-immune owner and log ERROR. When the owner is
# absent/empty/unknown (today's core, cron/CLI processes sharing the
# registry, pre-Option-1 spawns) it is a PURE PASS-THROUGH — it never
# suppresses a legitimate Option Z wakeup on uncertainty (Option Z must keep
# working).
_ENV_IMMUNE_OWNER_ATTRS = ("spawn_session_id", "owner_session_id", "turn_session_id")


def _env_immune_spawn_owner(proc_session) -> str:
    """Return the env-immune spawn-time owner sid from the ProcessSession, or
    "" when none is available (the only contract today; forward-compatible)."""
    if proc_session is None:
        return ""
    for attr in _ENV_IMMUNE_OWNER_ATTRS:
        try:
            val = getattr(proc_session, attr, "")
        except Exception:
            val = ""
        if val:
            return str(val)
    return ""


def _resolve_wakeup_target(
    *,
    process_id: str,
    session_key_resolved_sid: str,
    proc_session,
) -> str:
    """Cross-check the session_key-resolved wakeup target against the
    env-immune spawn owner. Returns the sid the server-side wakeup turn should
    actually target.

    - Owner unknown/empty  -> pass-through (return session_key_resolved_sid).
    - Owner == resolved    -> pass-through (the normal + post-Option-1 case).
    - Owner != resolved    -> POSITIVE mismatch: log ERROR and RE-ROUTE to the
      env-immune owner (do NOT wake the wrong session — this is the exact
      agent.log:6632 cross-session misroute).
    """
    resolved = str(session_key_resolved_sid or "")
    owner = _env_immune_spawn_owner(proc_session)
    if not owner or owner == resolved:
        return resolved
    logger.error(
        "xsession wakeup misroute BLOCKED (Option 3 safety net): process %r "
        "session_key resolved to session %r but the env-immune spawn owner "
        "is %r — re-routing the server-side wakeup to the true owner. This "
        "means a per-turn session-identity capture race occurred upstream "
        "(Option 1 should have prevented it); investigate streaming.py "
        "_set_turn_session_identity coverage.",
        process_id, resolved, owner,
    )
    return owner


def _process_one(evt: dict) -> None:
    """Route a single completion_queue event to the matching WebUI session."""
    from api import config as _cfg

    # Hoist the process-registry import once per event: it was imported in
    # three separate blocks below (session_key recovery, env-immune owner
    # cross-check, upstream is_completion_consumed dedupe) on every completion
    # event, paying repeated import-system overhead. Single local rebind keeps
    # the ImportError fallback contract (process_registry may be missing in
    # cut-down vendoring) while collapsing to one lookup per call.
    try:
        from tools.process_registry import process_registry as _process_registry
    except Exception:
        _process_registry = None

    process_id = str(evt.get("session_id") or "")
    session_key = str(evt.get("session_key") or "")
    # Root-cause fix (t_0f447014): the notify_on_complete completion event
    # enqueued by ProcessRegistry._move_to_finished() carries NO "session_key"
    # field — only the watch_match enqueue includes one. Without it the old
    # `evt.get("session_key") or process_id` fell back to the process id
    # ("proc_xxxx"), which is never a PROCESS_SESSION_INDEX key (only
    # webui_session_id -> webui_session_id is registered at chat-start), so
    # every wakeup was silently dropped here and the frontend never POSTed an
    # ack. Recover the spawn-time session_key from the process registry's
    # ProcessSession: the terminal tool captured it synchronously at spawn
    # (while the turn's env was active), so it survives the turn-end env
    # restore and is the WebUI session_id for WebUI-spawned processes.
    if not session_key and process_id:
        try:
            if _process_registry is not None:
                _ps = _process_registry.get(process_id)
                if _ps is not None and getattr(_ps, "session_key", ""):
                    session_key = str(_ps.session_key)
        except Exception:
            logger.debug(
                "session_key recovery from process registry failed for %r",
                process_id,
                exc_info=True,
            )
    if not session_key:
        logger.debug(
            "process_complete drop: no recoverable session_key for process_id=%r",
            process_id,
        )
        return
    with _cfg.PROCESS_SESSION_INDEX_LOCK:
        session_id = _cfg.PROCESS_SESSION_INDEX.get(session_key)
    if not session_id:
        # No mapping — could be a cron/gateway process that uses the same
        # registry but a non-WebUI session_key. Ignore.
        logger.debug("process_complete drop: no session mapping for key=%r", session_key)
        return
    # ── xsession wakeup misroute defense-in-depth (Option 3) ──────────────
    # session_id above came from PROCESS_SESSION_INDEX.get(session_key), and
    # session_key was captured by the terminal tool from the (historically
    # racy) process-global env at spawn. Option 1 binds the per-turn identity
    # to a contextvar so that capture is no longer racy — but as an INDEPENDENT
    # safety net, cross-check the resolved target against the env-immune
    # spawn owner (when the core ProcessSession exposes one). On a positive
    # mismatch this re-routes the wakeup (and the live-view emit + dedupe
    # markers below) to the TRUE owner instead of waking the wrong session.
    # Pure pass-through when no env-immune owner is available (today's core,
    # cron/CLI procs, pre-Option-1 spawns) — never suppresses a valid wakeup.
    try:
        _ps_xs = _process_registry.get(process_id) if (_process_registry is not None and process_id) else None
    except Exception:
        _ps_xs = None
    session_id = _resolve_wakeup_target(
        process_id=process_id,
        session_key_resolved_sid=session_id,
        proc_session=_ps_xs,
    )
    # ── Idempotency vs the REAL merged upstream #2279 (shared dedupe key) ──
    # The real merged #2279 next-turn drain
    # (api/streaming._drain_webui_process_notifications) dedupes ONLY via
    # process_registry.is_completion_consumed() / _completion_consumed — it
    # does NOT populate BG_TASK_COMPLETE_EVENTS_SEEN (that set is ours-original
    # and private to this module). So the cross-A/B shared dedupe contract is
    # process_registry._completion_consumed, NOT BG_TASK_COMPLETE_EVENTS_SEEN.
    # If the upstream A-drain already delivered this process_id (A-first
    # order), it marked _completion_consumed; B must early-return here or it
    # would double-fire a wakeup. This guard aligns our B-drain to the real
    # upstream key (verified against origin/master streaming.py).
    if process_id:
        try:
            if _process_registry is not None and _process_registry.is_completion_consumed(process_id):
                return
        except Exception:
            logger.debug(
                "is_completion_consumed check failed on B drain; "
                "falling back to BG_TASK_COMPLETE_EVENTS_SEEN gate",
                exc_info=True,
            )
    # Secondary (ours-original) idempotency: if we've already emitted for this
    # (session_id, process_id) pair via THIS module, skip the duplicate. Two
    # _move_to_finished() callers (kill_process racing the reader thread) can
    # occasionally enqueue twice despite the process_registry guard.
    with _cfg.BG_TASK_COMPLETE_EVENTS_SEEN_LOCK:
        seen = _cfg.BG_TASK_COMPLETE_EVENTS_SEEN.setdefault(session_id, set())
        if process_id and process_id in seen:
            return
        if process_id:
            seen.add(process_id)
    payload = _build_payload(evt, session_id)
    _emit_bg_task_complete_events_coalesced(session_id, payload)
    _cfg.PENDING_BG_TASK_COMPLETIONS.add(session_id)
    # Mark the event consumed in the agent's process registry so the REAL
    # merged PR #2279's next-turn drain
    # (api/streaming._drain_webui_process_notifications) treats this process_id
    # as already-delivered and does not re-fire a wakeup (B-first order).
    # This is the SHARED upstream dedupe key (see _mark_registry_completion_
    # consumed for the coupling contract + why a future rename now fails loud).
    if process_id:
        _mark_registry_completion_consumed(process_id)

    # ── Option Z (PRIMARY): server-side wakeup, NO browser round-trip ──────
    # The SSE emit above is now demoted to a pure live-view layer (an open tab
    # streams the turn live via the per-session SSE channel). The ACTUAL agent
    # wakeup is started HERE, server-side, so a CLOSED tab still gets the turn
    # — parity with how CLI / Telegram / gateway self-wake from a
    # notify_on_complete completion. This is the fix for the structural flaw:
    # "fire a long background task, close the tab, come back later" is THE
    # primary background-task use case and browser-mediated wakeup could never
    # serve it.
    #
    #   - turn ACTIVE → do NOT start a turn. Leave the PENDING_PROCESS_
    #     COMPLETIONS marker so PR #2279's next-turn drain
    #     (api/streaming._drain_webui_process_notifications) injects the wakeup
    #     when the active turn ends. (That path already works when a turn is
    #     active — it was never the gap.)
    #   - turn IDLE → start a new server-side turn directly with wakeup_prompt
    #     as the user message (the real gap Option Z closes).
    #
    # Idempotency is already guaranteed above: BG_TASK_COMPLETE_EVENTS_SEEN +
    # the registry _completion_consumed marker mean this process_id reached
    # here at most once, so the wakeup turn starts at most once.
    try:
        # ``wakeup_prompt`` is server-internal state used only by the
        # Option Z server-side wakeup; it was previously surfaced on the
        # SSE payload but T1 trimmed the payload to the minimal shape
        # `{session_id, task_id, completed_at, summary?, event_id}`, so
        # we derive the prompt directly from the evt here (same source the
        # prior _build_payload used).
        wakeup_prompt = format_wakeup_prompt(evt).strip()
        if wakeup_prompt:
            if _session_has_active_turn(session_id):
                # Defer-path fix: persist the prompt so a turn-teardown
                # idle-hook can redeliver it once the session goes idle.
                # The OLD behavior only logged + left a bare
                # PENDING_BG_TASK_COMPLETIONS session flag; the prompt was
                # discarded and the next-turn drain reads completion_queue
                # (already emptied by THIS drain thread), so for an
                # autonomous agent with no next user turn the wakeup was
                # lost forever. process_id is already in
                # BG_TASK_COMPLETE_EVENTS_SEEN + the registry
                # _completion_consumed marker (set above), so persisting it
                # here cannot cause a double-fire — the atomic claim in
                # ``claim_deferred_wakeups`` guarantees exactly one delivery.
                record_deferred_wakeup(session_id, process_id, wakeup_prompt)
                logger.debug(
                    "server-side wakeup deferred: turn active for session %s "
                    "(persisted for turn-teardown idle-hook redelivery)",
                    session_id,
                )
            else:
                # Idle-path sibling of the F1 (409/teardown) fix: pass
                # ``process_id`` so that if this idle wakeup's daemon thread
                # loses the per-session lock race and 409s, the re-defer in
                # ``_start_server_side_wakeup_turn`` records the entry WITH its
                # process_id — keeping the ``record_deferred_wakeup`` dedup
                # guard (``if process_id and any(...)``) live on that re-defer
                # path so a second 409 race cannot accumulate a duplicate
                # deferred entry (which would deliver the same wakeup twice).
                _start_server_side_wakeup_turn(
                    session_id, wakeup_prompt, process_id=process_id
                )
    except Exception:
        logger.warning(
            "server-side wakeup dispatch failed for session %s", session_id, exc_info=True
        )


def record_deferred_wakeup(session_id: str, process_id: str, wakeup_prompt: str) -> None:
    """Persist a deferred process-completion wakeup for later redelivery.

    Called from ``_process_one`` when a completion arrives while a turn is
    active (the Option Z drain branch cannot start a turn — it would 409).
    The turn-teardown idle-hook (``drain_deferred_wakeups_for_session``)
    redelivers it once the session goes idle, OR the PR #2279 next-turn drain
    claims it if a user turn comes first. Whoever claims first wins (atomic
    pop in ``claim_deferred_wakeups``); the other finds nothing.

    Idempotent per process_id: if the same process_id is already queued for
    this session (kill_process racing the reader thread), it is not appended
    twice. Best-effort — never raises into the drain loop.
    """
    if not session_id or not wakeup_prompt:
        return
    from api import config as _cfg

    try:
        with _cfg.DEFERRED_PROCESS_WAKEUPS_LOCK:
            entries = _cfg.DEFERRED_PROCESS_WAKEUPS.setdefault(session_id, [])
            if process_id and any(
                e.get("process_id") == process_id for e in entries
            ):
                return
            entries.append(
                {"process_id": process_id, "wakeup_prompt": wakeup_prompt}
            )
    except Exception:
        logger.debug(
            "record_deferred_wakeup failed for session %s", session_id, exc_info=True
        )


def claim_deferred_wakeups(session_id: str) -> list[dict]:
    """Atomically remove and return all deferred wakeups for *session_id*.

    The single-delivery guarantee for the defer path: the dict ``pop`` under
    ``DEFERRED_PROCESS_WAKEUPS_LOCK`` means whichever caller runs first
    (turn-teardown idle-hook OR PR #2279 next-turn drain) gets the entries and
    delivers them; every subsequent caller gets ``[]``. This is what makes the
    teardown hook idempotent with the next-turn drain (no double-fire) AND
    prevents a wakeup loop (the wakeup turn's own teardown re-runs the hook,
    finds nothing already-claimed → no re-fire).
    """
    if not session_id:
        return []
    from api import config as _cfg

    try:
        with _cfg.DEFERRED_PROCESS_WAKEUPS_LOCK:
            return _cfg.DEFERRED_PROCESS_WAKEUPS.pop(session_id, []) or []
    except Exception:
        logger.debug(
            "claim_deferred_wakeups failed for session %s", session_id, exc_info=True
        )
        return []


def drain_deferred_wakeups_for_session(session_id: str) -> int:
    """Turn-teardown idle-hook: redeliver deferred wakeups once idle.

    Called from ``api/streaming`` right AFTER ``unregister_active_run`` so
    ``_session_has_active_turn`` no longer counts the just-ended stream. This
    makes the active-at-completion case symmetric with the idle-at-completion
    case: idle now → fire now (Option Z idle branch); busy now → fire here
    when the turn ends and the session goes idle.

    Multi-stream / cancel-reconnect guard: if ANY other ACTIVE_RUNS row still
    exists for this session (a second stream from cancel/reconnect), the
    session is NOT yet idle — leave the deferred entries untouched so a later
    teardown (or the next-turn drain) delivers them. Only the teardown of the
    LAST active stream for the session claims + fires.

    Returns the number of wakeup turns started (0 when nothing pending or the
    session is still busy). Best-effort — never raises into the streaming
    teardown thread; the actual turn is started on the same throwaway daemon
    thread the idle branch uses, so this never blocks teardown.
    """
    if not session_id:
        return 0
    from api import config as _cfg

    try:
        # Multi-stream guard: only fire when the session is TRULY idle.
        if _session_has_active_turn(session_id):
            return 0
        # Peek without claiming: avoid taking the entries then discovering
        # there is nothing to do under contention.
        with _cfg.DEFERRED_PROCESS_WAKEUPS_LOCK:
            if not _cfg.DEFERRED_PROCESS_WAKEUPS.get(session_id):
                return 0
        # Atomic claim — exactly one caller gets the entries.
        entries = claim_deferred_wakeups(session_id)
        if not entries:
            return 0
        # The session-level PENDING marker is server-internal telemetry; the
        # real delivery is the prompt(s) we just claimed. Discard it now that
        # the deferred wakeups are owned by this teardown.
        try:
            _cfg.PENDING_BG_TASK_COMPLETIONS.discard(session_id)
        except Exception:
            logger.debug(
                "PENDING discard failed for session %s", session_id, exc_info=True
            )
        started = 0
        # Greptile P1 fix: do NOT fire one daemon-threaded wakeup per entry in
        # a tight loop. Each ``_start_server_side_wakeup_turn`` spawns a daemon
        # thread that races for the per-session agent lock; only ONE can win,
        # the rest 409. Since we already claimed + popped every entry (line
        # ~938) and discarded the PENDING marker, the losers' prompts would be
        # permanently lost. Instead: start exactly the FIRST prompt, and
        # re-defer the remaining entries so each subsequent turn-teardown
        # (or next-turn drain) delivers the next one — one wakeup per turn,
        # which matches the single-prompt-per-turn design and the
        # BG_TASK_COMPLETE_EVENTS_SEEN dedup (no double-fire).
        leftover = [e for e in entries if str((e or {}).get("wakeup_prompt") or "").strip()]
        if leftover:
            first = leftover[0]
            # Re-defer entries 2..N BEFORE starting the first turn, so they are
            # already persisted if the first wakeup's own teardown re-runs this
            # hook and tries to claim them.
            for entry in leftover[1:]:
                record_deferred_wakeup(
                    session_id,
                    str((entry or {}).get("process_id") or ""),
                    str((entry or {}).get("wakeup_prompt") or "").strip(),
                )
            _start_server_side_wakeup_turn(
                session_id,
                str((first or {}).get("wakeup_prompt") or "").strip(),
                process_id=str((first or {}).get("process_id") or ""),
            )
            started = 1
        if started:
            logger.info(
                "turn-teardown idle-hook redelivered %d deferred wakeup(s) "
                "for session %s",
                started,
                session_id,
            )
        return started
    except Exception:
        logger.warning(
            "drain_deferred_wakeups_for_session failed for session %s",
            session_id,
            exc_info=True,
        )
        return 0


def _session_has_active_turn(session_id: str) -> bool:
    """True if a foreground/streaming agent turn is currently active for *session_id*.

    The drain thread has no Session object, so we key on ACTIVE_RUNS — the
    worker-lifecycle registry that this module already uses (see
    ``_emit_to_session_streams``) to map a stream back to its owning session.
    ACTIVE_RUNS is registered at agent-worker start and removed in the worker's
    outer ``finally``, so it survives cancel/reconnect races better than
    STREAMS. There is a brief window where ``_start_chat_stream_for_session``
    has populated STREAMS but the worker thread has not yet called
    ``register_active_run``; in that window this returns False and the
    subsequent ``start_session_turn`` is rejected with a 409 by
    ``_start_chat_stream_for_session``'s own active-stream guard — i.e. the
    same lock /api/chat/start uses is the authoritative race backstop.
    """
    from api import config as _cfg

    try:
        with _cfg.ACTIVE_RUNS_LOCK:
            for _stream_id, meta in (_cfg.ACTIVE_RUNS or {}).items():
                if isinstance(meta, dict) and meta.get("session_id") == session_id:
                    return True
    except Exception:
        logger.debug("ACTIVE_RUNS active-turn check failed", exc_info=True)
    return False


def _start_server_side_wakeup_turn(
    session_id: str, wakeup_prompt: str, *, process_id: str = ""
) -> None:
    """Start an agent turn server-side for a process_complete wakeup (Option Z).

    Runs on a short-lived daemon thread so the drain loop NEVER blocks:
    ``start_session_turn`` itself spawns the agent worker thread, but does
    synchronous session-load / workspace / model resolution first, which must
    not stall the single drain thread shared by every WebUI session.

    Concurrency + idempotency are enforced by the layers below, not here:
      - ``start_session_turn`` → ``_start_chat_stream_for_session`` serializes
        on the per-session agent lock and returns ``_status=409`` if a turn is
        already active. A human ``/api/chat/start`` racing this wakeup wins
        (one starts, the other 409s). On 409 we re-queue the prompt via
        ``record_deferred_wakeup`` (see below) so the racing turn's own
        teardown idle-hook — or PR #2279's next-turn drain — redelivers it.
      - ``BG_TASK_COMPLETE_EVENTS_SEEN`` already deduped this process_id in
        ``_process_one`` before we were called, so a process wakes at most once.

    Why re-queue on 409 instead of trusting the PENDING marker: this helper is
    called from two sites. The idle branch of ``_process_one`` leaves the
    PENDING_BG_TASK_COMPLETIONS marker intact, but the teardown-hook caller
    ``drain_deferred_wakeups_for_session`` has ALREADY atomically claimed the
    deferred entry and discarded the marker before spawning this thread. So in
    the teardown path a 409 would otherwise lose the wakeup permanently —
    nothing is left to drain. Re-queuing here is uniformly correct: it is
    idempotent per ``process_id`` (``record_deferred_wakeup`` dedupes) and the
    claim in ``claim_deferred_wakeups`` is atomic, so re-queue can never cause
    a double delivery.
    """

    def _runner() -> None:
        try:
            from api.routes import start_session_turn

            resp = start_session_turn(
                session_id, wakeup_prompt, source="process_wakeup"
            )
            status = int((resp or {}).get("_status", 200) or 200)
            if status == 409:
                # Raced an active turn (e.g. a human /api/chat/start, or a
                # sibling deferred-wakeup thread). Re-defer this prompt so it
                # is delivered by the winning turn's teardown / next-turn drain
                # instead of being lost. The atomic claim in
                # ``claim_deferred_wakeups`` still guarantees exactly-once
                # delivery, and BG_TASK_COMPLETE_EVENTS_SEEN already deduped
                # this process_id, so re-recording cannot double-fire.
                if wakeup_prompt:
                    record_deferred_wakeup(session_id, process_id, wakeup_prompt)
                logger.debug(
                    "server-side wakeup raced an active turn for session %s; "
                    "re-deferred for redelivery on next teardown/turn",
                    session_id,
                )
            elif status >= 400:
                logger.warning(
                    "server-side wakeup failed for session %s: status=%s err=%r",
                    session_id,
                    status,
                    (resp or {}).get("error"),
                )
            else:
                logger.info(
                    "server-side wakeup turn started for session %s (stream_id=%s)",
                    session_id,
                    (resp or {}).get("stream_id"),
                )
        except Exception:
            logger.warning(
                "server-side wakeup turn raised for session %s",
                session_id,
                exc_info=True,
            )

    threading.Thread(
        target=_runner,
        name=f"hermes-webui-process-wakeup-{str(session_id)[:8]}",
        daemon=True,
    ).start()


def _drain_loop() -> None:
    try:
        from tools import process_registry as _pr_mod  # noqa: F401
        from tools.process_registry import process_registry
    except Exception as exc:
        logger.warning("bg_task_complete drain unavailable: %s", exc)
        return
    logger.info("bg_task_complete drain thread started")
    while not _DRAIN_STOP.is_set():
        try:
            evt = process_registry.completion_queue.get(timeout=1.0)
        except Exception:
            # queue.Empty or transient — re-check stop flag and continue.
            continue
        if not isinstance(evt, dict):
            continue
        try:
            _process_one(evt)
        except Exception:
            logger.warning("bg_task_complete event handling failed", exc_info=True)


def register_process_session(session_key: str, session_id: str) -> None:
    """Bind a process-registry session_key to a WebUI session_id.

    Called at chat-start time, before the agent thread spawns any background
    processes. The same ``session_key`` is exported to the child via
    ``HERMES_SESSION_KEY`` (already done by streaming.py), so when the child
    pushes onto ``completion_queue`` it carries the key we registered.
    """
    if not session_key or not session_id:
        return
    from api import config as _cfg

    with _cfg.PROCESS_SESSION_INDEX_LOCK:
        _cfg.PROCESS_SESSION_INDEX[str(session_key)] = str(session_id)


def unregister_process_session(session_key: str) -> None:
    if not session_key:
        return
    from api import config as _cfg

    with _cfg.PROCESS_SESSION_INDEX_LOCK:
        _cfg.PROCESS_SESSION_INDEX.pop(str(session_key), None)


def start_drain_thread() -> bool:
    """Start the background drain thread idempotently. Returns True on first start."""
    global _DRAIN_THREAD
    if _DRAIN_THREAD is not None and _DRAIN_THREAD.is_alive():
        return False
    _DRAIN_STOP.clear()
    _DRAIN_THREAD = threading.Thread(
        target=_drain_loop,
        name="hermes-webui-bg-task-complete-drain",
        daemon=True,
    )
    _DRAIN_THREAD.start()
    return True


def stop_drain_thread(timeout: float = 2.0) -> None:
    _DRAIN_STOP.set()
    th = _DRAIN_THREAD
    if th is not None and th.is_alive():
        th.join(timeout=timeout)
