Files
hermes-agent/tools/async_delegation.py
teknium1 576accd92b refactor(sqlite): one open_db/transaction layer for every small store; plugin DBs use the WAL fallback
Twelve modules each carried their own sqlite3.connect + PRAGMA + `with conn:`
stack. The #69567 fd-leak fix (a `with conn:` commits but never closes, so each
call leaked a connection and its WAL/SHM fds until GC) was pasted as code plus
docstring into six of them and hosted_room_policy_checkpoint never received
it; plugins/plugin_storage.plugin_db was the only production caller issuing a
raw `PRAGMA journal_mode=WAL`, bypassing the network-FS fallback, the
WAL-reset-bug gate and the never-live-downgrade invariant that
hermes_state_wal.apply_wal_with_fallback carries.

hermes_cli/sqlite_util.py (already home to add_column_if_missing/write_txn,
imported by cron, gateway and hermes_cli alike) gains `open_db(path, *,
db_label, busy_timeout_ms, wal, foreign_keys, synchronous_full, row_factory,
check_same_thread, wal_lock_retries, initialize)` and `transaction(conn,
immediate=)`; cron/ledger.py is deleted and hosted_rooms_common's
open_sqlite/connect/transaction become 1-3 line forwarders. Migrated:
agent/verification_evidence, cron/{executions,incidents,notepad,
delivery_queue}, gateway/{delivery_ledger,hosted_room_policy_checkpoint,
hosted_rooms_common (-> hosted_rooms, hosted_room_driver)}, hermes_cli/
projects_db, tools/async_delegation, plugins/plugin_storage.

Behavior changes (each module keeps its effective PRAGMA set otherwise):
- hosted_room_policy_checkpoint: connection now closed after every use and
  on init failure (was leaked per call), busy_timeout PRAGMA set explicitly.
- projects_db: gains busy_timeout=5000 (was the sqlite3 default 5 s connect
  timeout with no PRAGMA); explicit and observable.
- delivery_ledger / async_delegation: busy_timeout PRAGMA now mirrors the
  10 s connect timeout they already had.
- plugin_storage.plugin_db: WAL through apply_wal_with_fallback (DELETE on
  network filesystems / WAL-reset-vulnerable builds instead of raw WAL);
  busy_timeout=5000.
- cron/incidents._redact_error: redact_sensitive_text(force=True) — the
  error text is persisted to disk.
- delivery_ledger's private duplicate-column guard and the unguarded
  `ALTER TABLE ADD COLUMN` sites (shared_metrics, api_server_run_idempotency,
  holographic store, kanban model_override) go through add_column_if_missing.
- hermes_state.py::_scrub_surrogates: dead byte-copy of
  hermes_state_messages._scrub_surrogates (0 callers) deleted.
2026-09-13 05:08:29 -07:00

1004 lines
55 KiB
Python

#!/usr/bin/env python3
"""Async (background) delegation registry behind ``delegate_task(background=true)``.
The parent dispatches a subagent on a module-level daemon executor and returns a handle
immediately. On completion a ``type="async_delegation"`` event (self-contained task-source
block) is pushed onto the SHARED ``process_registry.completion_queue`` the CLI/gateway drain
while idle, so results surface as a NEW turn (never mid-turn) and inherit its de-dup and
crash-recovery wiring. Only the async lifecycle lives here; the child run is an injected ``runner``."""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, Optional
from hermes_constants import get_hermes_home
from tools.daemon_pool import DaemonThreadPoolExecutor
from tools.thread_context import propagate_context_to_thread
logger = logging.getLogger(__name__)
# ── Module-level state ──────────────────────────────────────────────────────
# Persistent daemon executor (never a `with ThreadPoolExecutor()` block, which
# would join on exit and defeat async); daemon workers can't hang a hard exit.
_executor: Optional[ThreadPoolExecutor] = None
_executor_lock = threading.Lock()
_executor_max_workers: int = 0
_records_lock = threading.Lock()
# delegation_id -> record dict; kept for the run plus a short completed tail.
_records: Dict[str, Dict[str, Any]] = {}
_DEFAULT_MAX_ASYNC_CHILDREN = 3
# Completed records retained (in memory and in the ledger) for status queries.
_MAX_RETAINED_COMPLETED = 50
_DURABLE_RETENTION_SECONDS = 7 * 24 * 60 * 60
_MAX_DURABLE_PENDING = 1000
# Cap retried deliveries so an unroutable row converges to terminal 'dropped'.
_MAX_DELIVERY_ATTEMPTS = 8
# Pending completions older than this are dropped on restart replay instead of
# re-run as a full-context turn; 48h keeps weekend results deliverable.
_MAX_COMPLETION_REPLAY_AGE_S = 48 * 3600.0
_DB_LOCK = threading.Lock()
# ── Stale-delegation detection (progress-based, on by default) ──────────────
# A runner wedged before returning never reaches its finalizer, so it would show
# "dispatched" forever. No wall-clock timeout (heavy work must never be killed for
# taking long): one monitor thread samples per-dispatch PROGRESS via an injected
# ``progress_fn``; a frozen child is interrupted, given a grace window to unwind via
# the normal finalize path, and only force-finalized (terminal ``stalled`` event) if
# it never returns. Thresholds mirror delegate_tool's sync heartbeat monitor.
_STALE_CHECK_INTERVAL = 30.0
_STALE_IDLE_SECONDS = 450.0
_STALE_IN_TOOL_SECONDS = 1200.0
_STALL_GRACE_SECONDS = 120.0
_monitor_lock = threading.Lock()
_monitor_thread: Optional[threading.Thread] = None
_monitor_stop = threading.Event()
_LIVE_STATES = {"running", "stalling", "finalizing"}
_ACTIVE_STATES = ("running", "stalling")
# Routing origin persisted at dispatch so a restart-recovered completion can
# reconstruct a full SessionSource (scope_id drives relay tenant egress).
_ROUTING_KEYS = ("scope_id", "user_id", "user_name")
# Structured stall metadata — additive, present only on stall finalizations.
_STALL_META_KEYS = ("stalled_after_quiet_seconds", "stall_threshold_seconds", "stall_phase", "stall_grace_seconds")
# Private stall bookkeeping on the record -> public field in list_async_delegations().
_STALL_FIELD_MAP = (("_stall_quiet_seconds", "stalled_after_quiet_seconds"),
("_stall_threshold_seconds", "stall_threshold_seconds"), ("_stall_in_tool", "stall_in_tool"))
# ── Durable ledger (state.db / async_delegations) ───────────────────────────
def _db_path():
return get_hermes_home() / "state.db"
def _connect() -> sqlite3.Connection:
from hermes_cli.sqlite_util import open_db
# Same state.db as hermes_state.SessionDB -- reuse its owner-only (0600)
# hardening so this writer doesn't create/leave the file (and its WAL
# sidecars) at the process umask. See hermes_state._secure_state_db_files.
from hermes_state import _secure_state_db_files
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
_secure_state_db_files(path, create_main=True)
# wal=False: SessionDB owns state.db's journal mode (_initialize_schema applies the barriers).
conn = open_db(path, db_label="state.db (async_delegation)", busy_timeout_ms=10_000,
wal=False, row_factory=None, initialize=_initialize_schema)
_secure_state_db_files(path)
return conn
def _initialize_schema(conn: sqlite3.Connection) -> None:
from hermes_state_repair import apply_durability_barriers
from hermes_state_schema import reconcile_state_schema
# Preserve the journal mode SessionDB configured on state.db: forcing WAL from
# every short-lived connection collides with live transcript/FTS writers.
apply_durability_barriers(conn)
# Single durable-shape authority: the canonical SCHEMA_SQL drives both
# table creation and column backfill (reconcile_state_schema replays the
# canonical DDL and reuses SessionDB's declarative reconciliation). This
# module previously carried its own CREATE TABLE + ALTER column list,
# which drifted from SCHEMA_SQL — same-name columns with different
# nullability/defaults depending on which authority touched the database
# first (#94691).
reconcile_state_schema(conn)
def _transaction():
from hermes_cli.sqlite_util import transaction
return transaction(_connect())
def _capture_routing_origin() -> Dict[str, Any]:
"""Snapshot scope_id/user_id/user_name on the PARENT thread (the daemon worker
has no contextvars) so a restart-replayed completion can rebuild a SessionSource.
Best-effort: empty values are omitted."""
try:
from gateway.session_context import get_session_env
return {k: v for k in _ROUTING_KEYS if (v := get_session_env(f"HERMES_SESSION_{k.upper()}", ""))}
except Exception: # noqa: BLE001 - routing origin is additive, never fatal
return {}
def _persist_dispatch(record: Dict[str, Any]) -> None:
now = time.time()
try:
from gateway.status import get_process_start_time
owner_started_at = get_process_start_time(os.getpid())
except Exception:
owner_started_at = None
task_payload = {
key: record.get(key)
for key in ("goal", "goals", "context", "toolsets", "role", "model", "is_batch", "task_indexes", *_ROUTING_KEYS)
if key in record}
with _DB_LOCK, _transaction() as conn:
conn.execute("""INSERT OR REPLACE INTO async_delegations
(delegation_id, origin_session, origin_ui_session_id,
parent_session_id, state, dispatched_at, updated_at,
delivery_state, delivery_attempts, owner_pid,
owner_started_at, task_json, origin_session_id)
VALUES (?, ?, ?, ?, 'running', ?, ?, 'pending', 0, ?, ?, ?, ?)""",
(record["delegation_id"], record.get("session_key", ""), record.get("origin_ui_session_id", ""),
record.get("parent_session_id"), record["dispatched_at"], now, os.getpid(), owner_started_at,
json.dumps(task_payload), record.get("origin_session_id", "")))
_prune_durable_records()
def _prune_durable_records() -> None:
"""Bound terminal history, preferring delivered records for deletion."""
cutoff = time.time() - _DURABLE_RETENTION_SECONDS
with _DB_LOCK, _transaction() as conn:
conn.execute(
"DELETE FROM async_delegations WHERE delivery_state='delivered' AND updated_at < ?", (cutoff,))
terminal_count = conn.execute(
"SELECT COUNT(*) FROM async_delegations WHERE state NOT IN ('running','finalizing')").fetchone()[0]
if terminal_count > _MAX_RETAINED_COMPLETED:
conn.execute("""DELETE FROM async_delegations WHERE delegation_id IN (
SELECT delegation_id FROM async_delegations
WHERE state NOT IN ('running','finalizing')
ORDER BY CASE delivery_state WHEN 'delivered' THEN 0 ELSE 1 END,
updated_at ASC LIMIT ?
)""", (terminal_count - _MAX_RETAINED_COMPLETED,))
pending_count = conn.execute("""SELECT COUNT(*) FROM async_delegations
WHERE state NOT IN ('running','finalizing') AND delivery_state='pending'""").fetchone()[0]
if pending_count > _MAX_DURABLE_PENDING:
conn.execute("""DELETE FROM async_delegations WHERE delegation_id IN (
SELECT delegation_id FROM async_delegations
WHERE state NOT IN ('running','finalizing') AND delivery_state='pending'
ORDER BY updated_at ASC LIMIT ?
)""", (pending_count - _MAX_DURABLE_PENDING,))
def _persist_completion(event: Dict[str, Any], result: Dict[str, Any]) -> None:
now = time.time()
with _DB_LOCK, _transaction() as conn:
conn.execute("""UPDATE async_delegations SET state=?, completed_at=?, updated_at=?,
event_json=?, result_json=?, delivery_state='pending'
WHERE delegation_id=?""",
(event.get("status", "completed"), event.get("completed_at", now), now,
json.dumps(event), json.dumps(result), event["delegation_id"]))
def record_unit_child(delegation_id: str, entry: Dict[str, Any]) -> None:
"""Durably record ONE finished child of a still-running multi-child unit on the unit's own row, so a crash before
the unit joins loses only the children that had not finished. Stored in ``result_json`` (overwritten by the real
result at finalize); ``recover_abandoned_delegations`` replays it. Best-effort: a failed write costs recovery
fidelity, never the live result."""
try:
with _DB_LOCK, _transaction() as conn:
row = conn.execute("SELECT result_json FROM async_delegations WHERE delegation_id=? AND state='running'",
(delegation_id,)).fetchone()
if row is None:
return
partial = json.loads(row[0] or "{}") or {}
results = [r for r in partial.get("results") or [] if r.get("task_index") != entry.get("task_index")]
results.append(entry)
conn.execute("UPDATE async_delegations SET result_json=?, updated_at=? WHERE delegation_id=? AND state='running'",
(json.dumps({"results": results, "partial": True}), time.time(), delegation_id))
except Exception: # noqa: BLE001 — recovery bookkeeping must never fail a live child
logger.warning("Async delegation %s: could not record finished child %s", delegation_id, entry.get("task_index"), exc_info=True)
def _recovered_results(task: Dict[str, Any], result_json: Optional[str], error: str) -> Optional[List[Dict[str, Any]]]:
"""Per-task results for an abandoned unit: recorded children as they finished, the rest ``unknown``."""
partial = json.loads(result_json or "{}") or {}
if not (task.get("is_batch") and partial.get("partial") and partial.get("results")):
return None
recorded = {r["task_index"]: r for r in partial["results"] if isinstance(r.get("task_index"), int)}
indexes = task.get("task_indexes") or list(range(len(task.get("goals") or [])))
return [recorded.get(i) or {"task_index": i, "status": "unknown", "summary": None, "error": error} for i in indexes]
def recover_abandoned_delegations() -> int:
"""Classify records whose owning process disappeared as outcome unknown; children a multi-child unit had already
recorded (``record_unit_child``) are replayed with their real results."""
try:
from gateway.status import _pid_exists, get_process_start_time
except Exception:
return 0
now, recovered = time.time(), 0
with _DB_LOCK, _transaction() as conn:
rows = conn.execute("""SELECT delegation_id, origin_session, origin_ui_session_id,
parent_session_id, dispatched_at, owner_pid,
owner_started_at, task_json, origin_session_id, result_json
FROM async_delegations WHERE state IN ('running','finalizing')""").fetchall()
for row in rows:
delegation_id, session_key, origin_ui, parent_id, dispatched_at, pid, started, task_json, origin_sid, result_json = row
if pid and _pid_exists(int(pid)) and (started is None or get_process_start_time(int(pid)) == int(started)):
continue
task = json.loads(task_json or "{}")
error = "Delegation owner exited before recording a terminal result; outcome unknown."
recovered_results = _recovered_results(task, result_json, error)
if recovered_results:
done = sum(1 for r in recovered_results if r.get("status") != "unknown")
error = (f"Delegation owner exited before the unit finished; {done}/{len(recovered_results)} child "
"results were recorded and are included below, the rest are unknown.")
event = {
"type": "async_delegation", "delegation_id": delegation_id, "session_key": session_key,
"origin_ui_session_id": origin_ui, "origin_session_id": origin_sid or "",
"parent_session_id": parent_id, "goal": task.get("goal", ""), "goals": task.get("goals"),
"context": task.get("context"), "toolsets": task.get("toolsets"), "role": task.get("role"),
"model": task.get("model"), "is_batch": bool(task.get("is_batch")),
"status": "unknown", "summary": None, "error": error,
**({"results": recovered_results} if recovered_results else {}),
"dispatched_at": dispatched_at, "completed_at": now,
**{k: task[k] for k in _ROUTING_KEYS if task.get(k)}}
result = {"status": "unknown", "summary": None, "error": event["error"],
**({"results": recovered_results} if recovered_results else {})}
conn.execute("""UPDATE async_delegations SET state='unknown', completed_at=?,
updated_at=?, event_json=?, result_json=?, delivery_state='pending'
WHERE delegation_id=?""", (now, now, json.dumps(event), json.dumps(result), delegation_id))
recovered += 1
return recovered
def restore_undelivered_completions(target_queue) -> int:
"""Enqueue durable pending completions as fresh turns after process start.
Restored events are stamped ``restored=True`` in memory only: they came from a PREVIOUS
process, so drains without an ownership filter must leave them for a consumer that can
prove ownership. Rows older than ``_MAX_COMPLETION_REPLAY_AGE_S`` are terminally dropped
instead of replaying a turn nobody is waiting on.
Every restored event is stamped ``restored=True`` (in-memory only — the stamp is added after the durable
payload is deserialized and is never persisted). Restored events originate from a *previous* process, so
no consumer in THIS process implicitly owns them: drain paths that run without an ownership filter (the
legacy single-session behavior) must leave them queued for a consumer that can positively prove
ownership, otherwise a brand-new session adopts a dead session's delegation results seconds after boot
(#64484).
"""
recover_abandoned_delegations()
now, restored = time.time(), 0
with _DB_LOCK, _transaction() as conn:
rows = conn.execute("""SELECT delegation_id, event_json, completed_at, dispatched_at
FROM async_delegations
WHERE state != 'running' AND delivery_state='pending' AND event_json IS NOT NULL
ORDER BY completed_at, delegation_id""").fetchall()
for delegation_id, payload, completed_at, dispatched_at in rows:
age_basis = completed_at or dispatched_at
if age_basis and (now - age_basis) > _MAX_COMPLETION_REPLAY_AGE_S:
conn.execute("""UPDATE async_delegations SET delivery_state='dropped',
delivery_claim=NULL, delivery_claimed_at=NULL,
updated_at=?
WHERE delegation_id=? AND delivery_state='pending'""", (now, delegation_id))
logger.warning("Async delegation %s: pending completion is %.1fh old "
"(cap %.1fh); terminally dropping the replay (result remains queryable).",
delegation_id, (now - age_basis) / 3600.0, _MAX_COMPLETION_REPLAY_AGE_S / 3600.0)
continue
evt = json.loads(payload)
if isinstance(evt, dict):
evt["restored"] = True
target_queue.put(evt)
restored += 1
return restored
def _update_delivery(sql: str, params: tuple) -> bool:
"""Run one UPDATE on the ledger; True iff exactly one row changed."""
with _DB_LOCK, _transaction() as conn:
return conn.execute(sql, params).rowcount == 1
def mark_completion_delivered(delegation_id: str) -> bool:
"""Atomically acknowledge successful injection of a durable completion."""
now = time.time()
return _update_delivery(
"""UPDATE async_delegations SET delivery_state='delivered', delivered_at=?, updated_at=?
WHERE delegation_id=? AND delivery_state!='delivered'""", (now, now, delegation_id))
def claim_completion_delivery(delegation_id: str, claim_id: str) -> bool:
"""Claim one pending completion across competing consumers/processes."""
now = time.time()
with _DB_LOCK, _transaction() as conn:
row = conn.execute(
"SELECT delivery_state FROM async_delegations WHERE delegation_id=?", (delegation_id,)).fetchone()
if row is None:
return True # legacy event created before durable dispatch
cur = conn.execute("""UPDATE async_delegations SET delivery_claim=?, delivery_claimed_at=?,
delivery_attempts=delivery_attempts+1, updated_at=?
WHERE delegation_id=? AND delivery_state='pending'
AND (delivery_claim IS NULL OR delivery_claimed_at < ?)""",
(claim_id, now, now, delegation_id, now - 300))
return cur.rowcount == 1
def is_interim_delegation_event(evt: Dict[str, Any]) -> bool:
"""An early per-task notice for a batch that is still running. It shares the batch's
``delegation_id`` but is NOT the durable completion: it must never claim, acknowledge or
dedup against the final result's row (independent review reproduced exactly that loss)."""
return evt.get("type") == "async_delegation" and bool(evt.get("task_failure_notice"))
def claim_event_delivery(evt: Dict[str, Any], consumer: str) -> Optional[str]:
"""Claim a durable delegation event; non-durable events (and interim notices) need no token."""
if is_interim_delegation_event(evt):
return ""
delegation_id = str(evt.get("delegation_id") or "") if evt.get("type") == "async_delegation" else ""
if not delegation_id:
return ""
claim_id = f"{consumer}:{os.getpid()}:{uuid.uuid4().hex}"
return claim_id if claim_completion_delivery(delegation_id, claim_id) else None
def release_completion_delivery(delegation_id: str, claim_id: str) -> bool:
"""Release a failed delivery claim so another consumer may retry. Attempts are
counted at claim time; once the budget is exhausted the row converges to
terminal ``dropped`` (only pending rows replay on restart)."""
now = time.time()
with _DB_LOCK, _transaction() as conn:
capped = conn.execute("""UPDATE async_delegations SET delivery_state='dropped',
delivery_claim=NULL, delivery_claimed_at=NULL, updated_at=?
WHERE delegation_id=? AND delivery_state='pending'
AND delivery_claim=? AND delivery_attempts>=?""",
(now, delegation_id, claim_id, _MAX_DELIVERY_ATTEMPTS))
if capped.rowcount == 1:
logger.warning("Async delegation %s exhausted its %d delivery attempts; "
"marking terminally dropped (result remains queryable).",
delegation_id, _MAX_DELIVERY_ATTEMPTS)
return True
cur = conn.execute("""UPDATE async_delegations SET delivery_claim=NULL,
delivery_claimed_at=NULL, updated_at=?
WHERE delegation_id=? AND delivery_state='pending'
AND delivery_claim=?""", (now, delegation_id, claim_id))
return cur.rowcount == 1
def defer_completion_delivery(delegation_id: str, claim_id: str) -> bool:
"""Return an unadmitted completion to pending without spending a delivery attempt."""
return _update_delivery("""UPDATE async_delegations SET delivery_claim=NULL,
delivery_claimed_at=NULL, delivery_attempts=MAX(0, delivery_attempts-1),
updated_at=?
WHERE delegation_id=? AND delivery_state='pending' AND delivery_claim=?""",
(time.time(), delegation_id, claim_id))
def drop_completion_delivery(delegation_id: str, claim_id: str) -> bool:
"""Terminally drop a claimed completion whose target is permanently gone (the
spawning session ended at an explicit user boundary such as /new or reset).
``dropped`` — not ``delivered`` — keeps the ack honest; not ``pending`` keeps
restart recovery from replaying it into a fail-closed drop forever."""
return _update_delivery("""UPDATE async_delegations SET delivery_state='dropped',
updated_at=?, delivery_claim=NULL,
delivery_claimed_at=NULL
WHERE delegation_id=? AND delivery_state='pending'
AND delivery_claim=?""", (time.time(), delegation_id, claim_id))
def complete_completion_delivery(delegation_id: str, claim_id: str) -> bool:
"""Acknowledge acceptance for the consumer holding this claim."""
now = time.time()
return _update_delivery("""UPDATE async_delegations SET delivery_state='delivered',
delivered_at=?, updated_at=?, delivery_claim=NULL,
delivery_claimed_at=NULL
WHERE delegation_id=? AND delivery_state='pending'
AND delivery_claim=?""", (now, now, delegation_id, claim_id))
def complete_event_delivery(evt: Dict[str, Any], claim_id: str) -> None:
_event_delivery(complete_completion_delivery, evt, claim_id)
def release_event_delivery(evt: Dict[str, Any], claim_id: str) -> None:
_event_delivery(release_completion_delivery, evt, claim_id)
def _event_delivery(fn, evt: Dict[str, Any], claim_id: str) -> None:
if claim_id and evt.get("type") == "async_delegation":
fn(str(evt.get("delegation_id") or ""), claim_id)
def get_durable_delegation(delegation_id: str) -> Optional[Dict[str, Any]]:
with _DB_LOCK, _transaction() as conn:
row = conn.execute("""SELECT origin_session, state, dispatched_at, completed_at,
result_json, delivery_state, delivery_attempts,
origin_session_id
FROM async_delegations WHERE delegation_id=?""", (delegation_id,)).fetchone()
return None if row is None else {
"delegation_id": delegation_id, "origin_session": row[0], "state": row[1], "dispatched_at": row[2],
"completed_at": row[3], "result": json.loads(row[4]) if row[4] else None, "delivery_state": row[5],
"delivery_attempts": row[6], "origin_session_id": row[7] or ""}
# ── In-memory registry queries ──────────────────────────────────────────────
def _get_executor(max_workers: int) -> ThreadPoolExecutor:
"""Lazily create (or grow in place, never shrink) the shared daemon executor. Raising
``_max_workers`` is enough: the next ``submit`` spawns threads up to the new cap."""
global _executor, _executor_max_workers
with _executor_lock:
if _executor is None:
_executor = DaemonThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="async-delegate")
_executor_max_workers = max_workers
elif max_workers > _executor_max_workers:
_executor._max_workers = max_workers
_executor_max_workers = max_workers
return _executor
def active_count() -> int:
"""Number of live async delegation UNITS (one per completion message: a task group or an ungrouped task)."""
with _records_lock:
return sum(1 for r in _records.values() if r.get("status") in _LIVE_STATES)
def active_task_count() -> int:
"""Number of running child subagents (a batch of N contributes N; a batch with
no goal list counts 1) — the truthful observability figure, unlike slots."""
with _records_lock:
return sum(
len(r.get("task_indexes") or r["goals"])
if r.get("is_batch") and isinstance(r.get("goals"), (list, tuple)) and r["goals"] else 1
for r in _records.values() if r.get("status") in {"running", "finalizing"})
def _session_records(statuses, session_key: str, origin_ui_session_id: str, parent_session_id: str) -> list:
"""Records in ``statuses`` owned by a session: any non-empty selector claims the
record — ``origin_ui_session_id`` (TUI tab), ``session_key`` (routing key at
dispatch), or ``parent_session_id`` (spawner's durable id — the right one for
gateway chats, whose session_key survives ``/new`` while the session id rotates)."""
selectors = [(field, wanted) for field, wanted in (
("origin_ui_session_id", origin_ui_session_id), ("session_key", session_key),
("parent_session_id", parent_session_id)) if wanted]
if not selectors:
return []
with _records_lock:
return [r for r in _records.values() if r.get("status") in statuses
and any(str(r.get(field) or "") == wanted for field, wanted in selectors)]
def has_live_for_session(session_key: str = "", origin_ui_session_id: str = "", parent_session_id: str = "") -> bool:
"""Whether a session still owns any live (running/stalling/finalizing) delegation."""
return bool(_session_records(_LIVE_STATES, session_key, origin_ui_session_id, parent_session_id))
def _new_delegation_id() -> str:
return f"deleg_{uuid.uuid4().hex[:8]}"
def _prune_completed_locked() -> None:
"""Drop the oldest completed records beyond the cap. Caller holds ``_records_lock``."""
completed = [(rid, r) for rid, r in _records.items() if r.get("status") != "running"]
completed.sort(key=lambda kv: kv[1].get("completed_at") or kv[1].get("dispatched_at") or 0)
for rid, _ in completed[: max(0, len(completed) - _MAX_RETAINED_COMPLETED)]:
_records.pop(rid, None)
def _current_origin_session_id() -> str:
"""Raw session id of the ORIGINATING api_server request, or ``""``. ``HERMES_SESSION_ID``
is unsafe here: building the child agent calls ``set_current_session_id(child.session_id)``
just before dispatch, so the wake would self-post into the subagent's own session. The
request-scoped ``HERMES_SESSION_CHAT_ID`` (raw X-Hermes-Session-Id on api_server) survives
child construction; on push platforms chat_id is a chat, not a session => ``""``."""
try:
from gateway.session_context import get_session_env
is_api = get_session_env("HERMES_SESSION_PLATFORM", "") == "api_server"
return (get_session_env("HERMES_SESSION_CHAT_ID", "") or "") if is_api else ""
except Exception:
return ""
# ── Dispatch ────────────────────────────────────────────────────────────────
def _single_crash(error: str, duration: float) -> Dict[str, Any]:
return {"status": "error", "summary": None, "error": error, "api_calls": 0, "duration_seconds": duration}
def _batch_crash(error: str, duration: float) -> Dict[str, Any]:
return {"results": [], "error": error, "total_duration_seconds": duration}
def _batch_status(combined: Dict[str, Any]) -> str:
"""Batch status: completed unless every child errored/was interrupted."""
child_results = combined.get("results") or []
ok = ("completed", "success")
return "error" if child_results and all(r.get("status") not in ok for r in child_results) else "completed"
def _dispatch(
*, delegation_id: str, goal: str, goals: Optional[List[str]], context: Optional[str],
toolsets: Optional[List[str]], role: str, model: Optional[str], session_key: str,
parent_session_id: Optional[str], runner: Callable[[], Dict[str, Any]], origin_ui_session_id: str,
origin_session_id: str, interrupt_fn: Optional[Callable[[], None]], max_async_children: int,
progress_fn: Optional[Callable[[], tuple]], capacity_error: str, slot_key: Optional[str] = None,
task_indexes: Optional[List[int]] = None,
) -> Dict[str, Any]:
"""Shared dispatch core for single (``goals is None``) and batch units. Capacity check +
record insert happen under ONE lock hold so concurrent dispatches can't both pass the check
and exceed the cap. At capacity the dispatch is REJECTED (never queued) so a runaway model
can't pile up unbounded background work. ``slot_key`` names the pool slot the unit occupies
(default: its own id); the units of one delegate_task call share the first unit's id so
splitting a call into per-group completions never consumes more capacity than the call did."""
is_batch = goals is not None
label = " batch" if is_batch else ""
classify = _batch_status if is_batch else (lambda r: r.get("status") or "completed")
crash_result = _batch_crash if is_batch else _single_crash
dispatched_at = time.time()
record: Dict[str, Any] = {
"delegation_id": delegation_id, "goal": goal, **({"goals": list(goals)} if is_batch else {}),
"context": context, "toolsets": list(toolsets) if toolsets else None, "role": role, "model": model,
"session_key": session_key, "origin_ui_session_id": origin_ui_session_id,
"origin_session_id": origin_session_id, "parent_session_id": parent_session_id,
**_capture_routing_origin(),
"status": "running", "dispatched_at": dispatched_at, "completed_at": None,
"interrupt_fn": interrupt_fn, **({"is_batch": True} if is_batch else {}), "progress_fn": progress_fn,
"slot_key": slot_key or delegation_id,
# Which of the call's ``goals`` this unit runs (None = all of them).
**({"task_indexes": list(task_indexes)} if task_indexes is not None else {}),
# Stale-monitor bookkeeping (see _stale_monitor_loop).
"_progress_token": None, "_progress_ts": dispatched_at, "_interrupted_at": None}
with _records_lock:
active_slots = {r.get("slot_key") or r["delegation_id"] for r in _records.values() if r.get("status") in _ACTIVE_STATES}
if record["slot_key"] not in active_slots and len(active_slots) >= max_async_children:
return {"status": "rejected", "error": capacity_error}
_records[delegation_id] = record
live_units = sum(1 for r in _records.values() if r.get("status") in _LIVE_STATES)
_persist_dispatch(record)
# Units of one call share a slot, so live units can exceed slots: size the pool by units or a
# unit queues behind a full pool and the stale monitor kills it before its child ever starts.
executor = _get_executor(max(max_async_children, live_units))
def _worker() -> None:
result: Dict[str, Any] = {}
status = "error"
with _records_lock:
rec = _records.get(delegation_id)
if rec is not None:
# The stall clock starts when the runner starts; a unit queued behind a full pool is not stalled.
rec.update(_started=True, _progress_ts=time.time())
try:
result = runner() or {}
status = classify(result)
except Exception as exc: # noqa: BLE001 — must never crash the worker
logger.exception(f"Async delegation{label} %s crashed", delegation_id)
result = crash_result(f"{type(exc).__name__}: {exc}", round(time.time() - dispatched_at, 2))
finally:
_finalize(delegation_id, result, status)
try:
# Propagate the dispatching profile so the detached child resolves get_hermes_home() correctly.
executor.submit(propagate_context_to_thread(_worker))
except Exception as exc: # pragma: no cover — pool submit failure is rare
with _records_lock:
_records.pop(delegation_id, None)
with _DB_LOCK, _transaction() as conn:
conn.execute("DELETE FROM async_delegations WHERE delegation_id=?", (delegation_id,))
return {"status": "rejected", "error": f"Failed to schedule async delegation{label}: {exc}"}
if progress_fn is not None:
_ensure_stale_monitor()
return {"status": "dispatched", "delegation_id": delegation_id}
def dispatch_async_delegation(
*, goal: str, context: Optional[str], toolsets: Optional[List[str]], role: str, model: Optional[str],
session_key: str, parent_session_id: Optional[str] = None, runner: Callable[[], Dict[str, Any]],
origin_ui_session_id: str = "", origin_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None,
max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, progress_fn: Optional[Callable[[], tuple]] = None,
) -> Dict[str, Any]:
"""Spawn ``runner`` on the daemon executor and return a handle immediately.
``session_key``/``parent_session_id`` are captured on the parent thread (the worker carries
no contextvars) and route the completion back to the spawning session.
``progress_fn() -> (token, in_tool)`` enables stale monitoring; omitted = unmonitored.
Returns ``{"status": "dispatched", "delegation_id"}`` or ``{"status": "rejected", "error"}``."""
delegation_id = _new_delegation_id()
handle = _dispatch(
delegation_id=delegation_id, goal=goal, goals=None, context=context,
toolsets=toolsets, role=role, model=model, session_key=session_key,
parent_session_id=parent_session_id, runner=runner,
origin_ui_session_id=origin_ui_session_id, origin_session_id=origin_session_id,
interrupt_fn=interrupt_fn, max_async_children=max_async_children, progress_fn=progress_fn,
capacity_error=(
f"Async delegation capacity reached ({max_async_children} running). Wait for one to finish "
"(its result will re-enter the chat), or run this task synchronously (background=false). "
"Raise delegation.max_concurrent_children in config.yaml to allow more concurrent background subagents."))
if handle["status"] == "dispatched":
logger.info("Dispatched async delegation %s (session_key=%s): %s",
delegation_id, session_key or "<cli>", (goal or "")[:80])
return handle
def dispatch_async_delegation_batch(
*, goals: List[str], context: Optional[str], toolsets: Optional[List[str]], role: str, model: Optional[str],
session_key: str, parent_session_id: Optional[str] = None, runner: Callable[[], Dict[str, Any]],
origin_ui_session_id: str = "", origin_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None,
max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, delegation_id: Optional[str] = None,
progress_fn: Optional[Callable[[], tuple]] = None, slot_key: Optional[str] = None,
task_indexes: Optional[List[int]] = None,
) -> Dict[str, Any]:
"""Dispatch a fan-out unit (a whole batch, or one ``group`` of a delegate_task call) as ONE
background unit: ``runner`` runs its tasks and returns the combined ``{"results": [...],
"total_duration_seconds": N}`` dict. The unit occupies ONE async slot — or joins the slot named
by ``slot_key`` (in-unit parallelism is bounded separately) — and produces a SINGLE completion
event carrying per-task ``results``."""
delegation_id = delegation_id or _new_delegation_id()
# ``goals`` is the whole call (result task_index indexes it); the unit's own goals label the record.
unit_goals = [goals[i] for i in task_indexes] if task_indexes is not None else list(goals)
n = len(unit_goals)
combined_goal = unit_goals[0] if n == 1 else f"{n} parallel subagents: " + "; ".join(g[:40] for g in unit_goals)
handle = _dispatch(
delegation_id=delegation_id, goal=combined_goal, goals=goals, context=context,
toolsets=toolsets, role=role, model=model, session_key=session_key,
parent_session_id=parent_session_id, runner=runner,
origin_ui_session_id=origin_ui_session_id, origin_session_id=origin_session_id,
interrupt_fn=interrupt_fn, max_async_children=max_async_children, progress_fn=progress_fn, slot_key=slot_key,
task_indexes=task_indexes,
capacity_error=(
f"Async delegation capacity reached ({max_async_children} running). Wait for one to finish "
"(its result will re-enter the chat), or raise delegation.max_concurrent_children in "
"config.yaml to allow more concurrent background units."))
if handle["status"] == "dispatched":
logger.info("Dispatched async delegation batch %s (%d task(s), session_key=%s)",
delegation_id, n, session_key or "<cli>")
return handle
# ── Finalization + completion events ────────────────────────────────────────
def _finalize(delegation_id: str, result: Any, status: str) -> None:
"""Atomically claim terminal delivery, push the completion event, then mark ``status``.
``result`` is a dict or a callable receiving the record snapshot (stall path). The record
stays active ("finalizing") until durable persistence and queue publication finish; otherwise
process shutdown can kill this daemon worker after status flips but before SQLite commits.
A second call for the same id (late runner return after a forced stall) is a no-op."""
with _records_lock:
record = _records.get(delegation_id)
if record is None or record.get("status") not in _ACTIVE_STATES:
return
record["status"] = "finalizing"
record["completed_at"] = time.time()
record["interrupt_fn"] = None # drop the closure; child is done
record["progress_fn"] = None # stop stale-monitor sampling
snapshot = dict(record)
_push_completion_event(snapshot, result(snapshot) if callable(result) else result, status)
with _records_lock:
if delegation_id in _records:
_records[delegation_id]["status"] = status
_prune_completed_locked()
def _push_completion_event(record: Dict[str, Any], result: Dict[str, Any], status: str) -> None:
"""Push a type='async_delegation' event onto the shared completion queue. Batch records
(``is_batch``) carry the per-task ``results`` list (plus live transcript paths, the
full-fidelity record of each child's run) instead of a single summary. Best-effort: failure
must not crash the worker, but it WOULD mean a silently-lost result, so we log loudly."""
is_batch = bool(record.get("is_batch"))
label = " batch" if is_batch else ""
try:
from tools.process_registry import process_registry
except Exception as exc: # pragma: no cover
logger.error(f"Async delegation{label} %s finished but process_registry import failed; "
"result lost: %s", record.get("delegation_id"), exc)
return
dispatched_at = record.get("dispatched_at") or time.time()
completed_at = record.get("completed_at") or time.time()
if is_batch:
payload = {
"is_batch": True, "results": result.get("results") or [],
"live_transcripts": result.get("live_transcripts"), "error": result.get("error"),
"total_duration_seconds": result.get("total_duration_seconds"),
**({"group": result["group"]} if result.get("group") is not None else {})}
else:
payload = {
"summary": result.get("summary"), "error": result.get("error"), "api_calls": result.get("api_calls", 0),
"duration_seconds": result.get("duration_seconds", round(completed_at - dispatched_at, 2))}
evt = {
"type": "async_delegation", "delegation_id": record.get("delegation_id"),
# session_key routes back to the originating gateway session; "" => CLI.
"session_key": record.get("session_key", ""),
"origin_ui_session_id": record.get("origin_ui_session_id", ""),
"origin_session_id": record.get("origin_session_id", ""),
"parent_session_id": record.get("parent_session_id"),
"goal": record.get("goal", ""), **({"goals": record.get("goals")} if is_batch else {}),
"context": record.get("context"), "toolsets": record.get("toolsets"), "role": record.get("role"),
"model": record.get("model") if is_batch else (result.get("model") or record.get("model")),
"status": status, **payload, "dispatched_at": dispatched_at, "completed_at": completed_at,
**({} if is_batch else {"exit_reason": result.get("exit_reason")}),
**{k: record[k] for k in _ROUTING_KEYS if record.get(k)},
**{k: result[k] for k in _STALL_META_KEYS if k in result}}
_persist_completion(evt, result)
try:
process_registry.completion_queue.put(evt)
except Exception as exc: # pragma: no cover
logger.error(f"Async delegation{label} %s: failed to enqueue completion event; "
"result lost: %s", record.get("delegation_id"), exc)
def push_task_failure_notice(delegation_id: str, entry: Dict[str, Any], *, n_tasks: int) -> None:
"""Surface ONE failed child of a still-running detached batch to the parent now, instead of
when the slowest sibling finishes. In a 1,393-agent run every wave-1 child died in a 401 storm
at 08:29 and the parent learned of it at 09:36, when the batch's "unknown outcome" block finally
arrived: 66 minutes of a dead wave with nothing running. The notice rides the same
``type="async_delegation"`` event shape as the batch result (so every drain/route/format path
treats it identically) with ``task_failure_notice=True`` and a single-entry ``results`` list; the
batch record is NOT finalized and its consolidated result still arrives as before."""
with _records_lock:
record = _records.get(delegation_id)
if record is None or record.get("status") not in _ACTIVE_STATES:
return
snapshot = dict(record)
try:
from tools.process_registry import process_registry
except Exception as exc: # pragma: no cover
logger.error("Async delegation batch %s: task failure notice dropped (process_registry import): %s", delegation_id, exc)
return
evt = {
"type": "async_delegation", "task_failure_notice": True, "is_batch": True, "n_tasks": n_tasks,
"delegation_id": delegation_id, "results": [entry],
"session_key": snapshot.get("session_key", ""),
"origin_ui_session_id": snapshot.get("origin_ui_session_id", ""),
"origin_session_id": snapshot.get("origin_session_id", ""),
"parent_session_id": snapshot.get("parent_session_id"),
"goal": snapshot.get("goal", ""), "goals": snapshot.get("goals"), "context": snapshot.get("context"),
"toolsets": snapshot.get("toolsets"), "role": snapshot.get("role"), "model": snapshot.get("model"),
"status": "running", "dispatched_at": snapshot.get("dispatched_at") or time.time(), "completed_at": time.time(),
**{k: snapshot[k] for k in _ROUTING_KEYS if snapshot.get(k)}}
try:
process_registry.completion_queue.put(evt)
except Exception as exc: # pragma: no cover
logger.error("Async delegation batch %s: failed to enqueue task failure notice: %s", delegation_id, exc)
# ── Stale monitor ───────────────────────────────────────────────────────────
def _ensure_stale_monitor() -> None:
"""Start (once) the stale-delegation monitor thread. One daemon thread serves
every dispatch; it exits when no monitorable records remain and is restarted
by the next dispatch with a ``progress_fn``."""
global _monitor_thread
with _monitor_lock:
if _monitor_thread is not None and _monitor_thread.is_alive():
return
_monitor_stop.clear()
_monitor_thread = threading.Thread(
target=_stale_monitor_loop, name="async-delegate-stale-monitor", daemon=True)
_monitor_thread.start()
def _sweep_stale_locked(now: float):
"""One monitor pass over ``_records``; caller holds ``_records_lock``. Returns
``(stalled, expired, any_monitorable)``: newly-stalling ``(delegation_id, quiet_for, in_tool)``
tuples, stalling ids past the grace window, and whether anything is left to monitor."""
stalled, expired, any_monitorable = [], [], False # (delegation_id, quiet_for, in_tool) / ids past grace
for record in _records.values():
status = record.get("status")
if status == "stalling":
any_monitorable = True
if now - (record.get("_interrupted_at") or now) >= _STALL_GRACE_SECONDS:
expired.append(record["delegation_id"])
continue
progress_fn = record.get("progress_fn")
if status != "running" or progress_fn is None:
continue
any_monitorable = True
if not record.get("_started"):
continue # queued behind a full pool: not stalled, but keep the monitor alive for when it starts
try:
token, in_tool = progress_fn()
except Exception:
# An unreadable child must not look permanently healthy —
# keep the last timestamp running instead of refreshing it.
token, in_tool = record.get("_progress_token"), False
if token != record.get("_progress_token"):
record.update(_progress_token=token, _progress_ts=now)
continue
quiet_for = now - (record.get("_progress_ts") or now)
limit = _STALE_IN_TOOL_SECONDS if in_tool else _STALE_IDLE_SECONDS
if quiet_for >= limit:
# Stall context feeds the terminal event and status listings.
record.update(
status="stalling", _interrupted_at=now, _stall_quiet_seconds=round(quiet_for, 2),
_stall_threshold_seconds=limit, _stall_in_tool=bool(in_tool))
stalled.append((record["delegation_id"], quiet_for, in_tool))
return stalled, expired, any_monitorable
def _call_interrupt(fn, msg: str, *args) -> bool:
"""Invoke an ``interrupt_fn``; True on success, else debug-log ``msg`` (+ exc)."""
if not callable(fn):
return False
try:
fn()
return True
except Exception as exc:
logger.debug(msg, *args, exc)
return False
def _stale_monitor_loop() -> None:
"""Sweep running delegations for stalled progress. A changed progress token refreshes the
record's timestamp; a frozen token past the idle/in-tool threshold marks the record
``stalling`` and calls ``interrupt_fn``; a ``stalling`` record still unreturned after the
grace window is force-finalized with a terminal ``stalled`` event."""
while not _monitor_stop.wait(_STALE_CHECK_INTERVAL):
now = time.time()
with _records_lock:
stalled, expired, any_monitorable = _sweep_stale_locked(now)
for delegation_id, quiet_for, in_tool in stalled:
logger.warning("Async delegation %s made no progress for %.0fs "
"(in_tool=%s) — interrupting; grace window %.0fs",
delegation_id, quiet_for, in_tool, _STALL_GRACE_SECONDS)
with _records_lock:
fn = (_records.get(delegation_id) or {}).get("interrupt_fn")
_call_interrupt(fn, "Async delegation %s stall interrupt failed: %s", delegation_id)
for delegation_id in expired:
_finalize(delegation_id, lambda rec, d=delegation_id: _stalled_result(d, rec), "stalled")
if not any_monitorable:
return
def _stalled_result(delegation_id: str, event_record: Dict[str, Any]) -> Dict[str, Any]:
"""Synthetic terminal result for a stalling delegation whose runner never returned."""
completed_at = event_record.get("completed_at") or time.time()
duration = round(completed_at - (event_record.get("dispatched_at") or completed_at), 2)
error = (
f"Async delegation {delegation_id} stalled: the detached subagent stopped making progress "
"(no new API calls, tool activity, or streamed tokens), did not respond to interruption, and never "
"produced a completion event. The worker may be wedged inside a model API call — this is a known "
"failure mode of long-lived gateway processes (#60203). Re-dispatch the task if it is still needed.")
logger.error("Async delegation %s force-finalized as stalled after %.0fs", delegation_id, duration)
# Structured stall metadata lets parents/UIs distinguish a stall-monitor
# kill from other failures without parsing the error string.
stall_in_tool = event_record.get("_stall_in_tool")
stall_meta = {
"stalled_after_quiet_seconds": event_record.get("_stall_quiet_seconds"),
"stall_threshold_seconds": event_record.get("_stall_threshold_seconds"),
"stall_phase": "in_tool" if stall_in_tool else "idle" if stall_in_tool is not None else None,
"stall_grace_seconds": _STALL_GRACE_SECONDS}
if event_record.get("is_batch"):
return {**_batch_crash(error, duration), **stall_meta}
return {**_single_crash(error, duration), "status": "stalled", "exit_reason": "stalled", **stall_meta}
# ── Observability + control ─────────────────────────────────────────────────
def _children_activity_from_token(token: Any, now: float) -> Optional[List]:
"""Parse a progress token into per-child activity dicts (best-effort): delegate_tool
emits one ``(api_call_count, current_tool, last_activity_ts)`` tuple per child;
foreign token shapes degrade to ``None`` entries."""
try:
parts = list(token)
except TypeError:
return None
out: List[Optional[Dict[str, Any]]] = []
for part in parts:
if not (isinstance(part, (list, tuple)) and len(part) >= 2):
out.append(None)
continue
entry: Dict[str, Any] = {"api_calls": part[0], "current_tool": part[1]}
if len(part) >= 3 and isinstance(part[2], (int, float)):
entry["seconds_since_activity"] = round(max(0.0, now - float(part[2])), 1)
out.append(entry)
return out
def list_async_delegations() -> List[Dict[str, Any]]:
"""Snapshot of async delegations (running + recently completed) without callables or private
monitor bookkeeping; adds computed live fields for UIs (``seconds_since_progress``,
``children_activity``/``in_tool`` sampled from ``progress_fn``) and stall context once tripped.
Safe to call from any thread. See #51690.
"""
now = time.time()
samplers: Dict[str, Callable] = {}
with _records_lock:
items = []
for r in _records.values():
item = {k: v for k, v in r.items() if k not in {"interrupt_fn", "progress_fn"} and not k.startswith("_")}
status = r.get("status")
if status in _ACTIVE_STATES:
if r.get("_progress_ts"):
item["seconds_since_progress"] = round(now - r["_progress_ts"], 1)
if callable(r.get("progress_fn")):
samplers[r["delegation_id"]] = r["progress_fn"]
if status in ("stalling", "stalled"):
for src, dst in _STALL_FIELD_MAP:
if r.get(src) is not None:
item[dst] = r.get(src)
items.append(item)
# Sample OUTSIDE the lock — progress_fn reads child-agent attributes and a
# slow/broken sampler must not block every dispatch/finalize.
for item in items:
fn = samplers.get(item.get("delegation_id"))
if fn is None:
continue
try:
token, in_tool = fn()
except Exception:
continue
activity = _children_activity_from_token(token, now)
if activity is not None:
item["children_activity"] = activity
item["in_tool"] = bool(in_tool)
return items
def _interrupt_records(targets: List[Dict[str, Any]], caller: str, reason: str, msg: str) -> int:
"""Call ``interrupt_fn`` on each record; log ``msg`` once; returns how many succeeded."""
count = sum(
_call_interrupt(r.get("interrupt_fn"), "%s: %s interrupt failed: %s", caller, r.get("delegation_id"))
for r in targets)
if count:
logger.info(msg, count, reason)
return count
def interrupt_all(reason: str = "shutdown") -> int:
"""Signal every running async delegation to stop (``/stop``, shutdown). Returns how
many. The child still emits a completion event (status='interrupted') via the
normal finalize path."""
with _records_lock:
targets = [r for r in _records.values() if r.get("status") in _ACTIVE_STATES]
return _interrupt_records(targets, "interrupt_all", reason, "Interrupted %d async delegation(s) (%s)")
def interrupt_for_session(
session_key: str = "", origin_ui_session_id: str = "", parent_session_id: str = "", reason: str = "session_end",
) -> int:
"""Signal running async delegations owned by ONE ending session to stop (any
selector matches, see ``_session_records``). Returns how many."""
targets = _session_records(_ACTIVE_STATES, session_key, origin_ui_session_id, parent_session_id)
return _interrupt_records(
targets, "interrupt_for_session", reason, "Interrupted %d async delegation(s) for ending session (%s)")
def _reset_for_tests() -> None:
"""Test-only: clear all state and tear down the executor + monitor."""
global _executor, _executor_max_workers, _monitor_thread
with _executor_lock:
if _executor is not None:
_executor.shutdown(wait=False)
_executor = None
_executor_max_workers = 0
_monitor_stop.set()
with _monitor_lock:
thread, _monitor_thread = _monitor_thread, None
if thread is not None and thread.is_alive():
thread.join(timeout=2)
with _records_lock:
_records.clear()
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def active_for_session(origin_ui_session_id: str) -> int:
"""Number of live async delegations owned by one UI session."""
if not origin_ui_session_id:
return 0
with _records_lock:
return sum(
1
for r in _records.values()
if r.get("status") in {"running", "stalling", "finalizing"}
and str(r.get("origin_ui_session_id") or "")
== origin_ui_session_id
)
# ---- END PLUGIN-COMPAT ----