Files
hermes-agent/tools/async_delegation.py
Teknium d4cec15b47 refactor(tools): first-wave simplification of tools/ (file ops split, lazy_deps, code_exec, approval, browser, delegate, mcp, skills, terminal, voice, media)
Behavior-neutral structural pass over tools/*: god-file extractions into
sibling modules (file_operations_common/lint/search, file_tools_paths/
read_tracking/write, code_execution_env/rpc, tool_search_catalog/names/
validation, tts_command_provider, ...), duplicate helper unification,
if/elif -> dispatch tables, dead-code removal, docstring compaction.
Tool schemas (get_tool_definitions) verified byte-identical to base.
2026-09-02 14:43:45 -07:00

1169 lines
50 KiB
Python

#!/usr/bin/env python3
"""
Async (background) delegation registry.
Backs ``delegate_task(background=true)``: the parent dispatches a subagent on
a module-level daemon executor and returns a handle immediately. When the
child finishes, a ``type="async_delegation"`` event is pushed onto the SHARED
``process_registry.completion_queue``, which the CLI and gateway already drain
while the agent is idle. Reusing that rail means completions surface as a NEW
turn (never spliced mid-turn, so role alternation and the prompt cache stay
intact) and inherit the queue's de-dup, crash-recovery and drain wiring.
The completion payload carries a self-contained task-source block (goal,
context, toolsets, model, dispatch time, status, result) so a parent deep in
unrelated context can use the result or re-dispatch.
This module owns ONLY the async lifecycle; the child build + run is injected
as a ``runner`` (``delegate_tool._run_single_child``).
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from typing import Any, Callable, Dict, Iterator, 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'
# instead of replaying on every restart forever.
_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 (e.g. stuck in its first API call) never
# reaches its finalizer, so the delegation shows "dispatched" forever. We do
# NOT use a wall-clock timeout (legitimate heavy work must never be killed for
# taking long); a single monitor thread samples per-dispatch PROGRESS via an
# injected ``progress_fn``. Advancing children are left alone; a frozen one is
# interrupted, given a grace window to unwind through the normal finalize path,
# and only force-finalized with a terminal ``stalled`` event if it never returns.
# Thresholds mirror delegate_tool's sync heartbeat monitor: idle stays tight,
# in-tool is much higher so legitimately slow tools can finish.
_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"),
)
def _copy_truthy(src: Dict[str, Any], dst: Dict[str, Any], keys) -> None:
for k in keys:
if src.get(k):
dst[k] = src[k]
def _copy_present(src: Dict[str, Any], dst: Dict[str, Any], keys) -> None:
for k in keys:
if k in src:
dst[k] = src[k]
# ── Durable ledger (state.db / async_delegations) ───────────────────────────
def _db_path():
return get_hermes_home() / "state.db"
def _connect() -> sqlite3.Connection:
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=10)
try:
_initialize_schema(conn)
except Exception:
conn.close() # don't leak the connection on PRAGMA/DDL failure
raise
return conn
def _initialize_schema(conn: sqlite3.Connection) -> None:
from hermes_state import apply_durability_barriers
# Preserve the journal mode SessionDB configured on state.db: forcing WAL
# from every short-lived connection needs an exclusive lock and collides
# with live transcript/FTS writers. The ledger works in WAL or DELETE mode.
apply_durability_barriers(conn)
conn.execute(
"""CREATE TABLE IF NOT EXISTS async_delegations (
delegation_id TEXT PRIMARY KEY,
origin_session TEXT NOT NULL,
origin_ui_session_id TEXT NOT NULL DEFAULT '',
parent_session_id TEXT,
state TEXT NOT NULL,
dispatched_at REAL NOT NULL,
completed_at REAL,
updated_at REAL NOT NULL,
event_json TEXT,
result_json TEXT,
delivery_state TEXT NOT NULL DEFAULT 'pending',
delivery_attempts INTEGER NOT NULL DEFAULT 0,
delivered_at REAL,
owner_pid INTEGER,
owner_started_at INTEGER,
task_json TEXT,
delivery_claim TEXT,
delivery_claimed_at REAL,
origin_session_id TEXT NOT NULL DEFAULT ''
)"""
)
columns = {row[1] for row in conn.execute("PRAGMA table_info(async_delegations)")}
for name, sql_type in (
("owner_pid", "INTEGER"),
("owner_started_at", "INTEGER"),
("task_json", "TEXT"),
("delivery_claim", "TEXT"),
("delivery_claimed_at", "REAL"),
# Raw api_server session id of the ORIGINATING request (wake target);
# without it restart-recovered completions are unroutable there.
("origin_session_id", "TEXT"),
):
if name not in columns:
conn.execute(f"ALTER TABLE async_delegations ADD COLUMN {name} {sql_type}")
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, and ALWAYS close it.
``with conn:`` only commits/rolls back; without the explicit close every
durable write leaks a connection (and WAL/SHM fds) until GC.
"""
conn = _connect()
try:
with conn:
yield conn
finally:
conn.close()
def _capture_routing_origin() -> Dict[str, Any]:
"""Snapshot scope_id/user_id/user_name from the dispatching turn.
Captured on the PARENT thread (the daemon worker has no contextvars) and
persisted so a restart-replayed completion can rebuild a SessionSource.
Best-effort: empty values are omitted.
"""
origin: Dict[str, Any] = {}
try:
from gateway.session_context import get_session_env
for key in _ROUTING_KEYS:
value = get_session_env(f"HERMES_SESSION_{key.upper()}", "")
if value:
origin[key] = value
except Exception: # noqa: BLE001 - routing origin is additive, never fatal
pass
return origin
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",
*_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 _delete_durable_delegation(delegation_id: str) -> None:
with _DB_LOCK, _transaction() as conn:
conn.execute("DELETE FROM async_delegations WHERE delegation_id=?", (delegation_id,))
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]
excess = max(0, terminal_count - _MAX_RETAINED_COMPLETED)
if excess:
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 ?
)""",
(excess,),
)
pending_count = conn.execute(
"""SELECT COUNT(*) FROM async_delegations
WHERE state NOT IN ('running','finalizing') AND delivery_state='pending'"""
).fetchone()[0]
overflow = max(0, pending_count - _MAX_DURABLE_PENDING)
if overflow:
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 ?
)""",
(overflow,),
)
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 recover_abandoned_delegations() -> int:
"""Classify records whose owning process disappeared as outcome unknown."""
try:
from gateway.status import _pid_exists, get_process_start_time
except Exception:
return 0
now = time.time()
recovered = 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
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_session_id) = row
live = False
if pid:
live = _pid_exists(int(pid))
if live and started is not None:
live = get_process_start_time(int(pid)) == int(started)
if live:
continue
task = json.loads(task_json or "{}")
event = {
"type": "async_delegation", "delegation_id": delegation_id,
"session_key": session_key, "origin_ui_session_id": origin_ui,
"origin_session_id": origin_session_id 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": "Delegation owner exited before recording a terminal result; outcome unknown.",
"dispatched_at": dispatched_at, "completed_at": now,
}
_copy_truthy(task, event, _ROUTING_KEYS)
result = {"status": "unknown", "summary": None, "error": event["error"]}
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 full-context turn nobody is waiting on.
"""
recover_abandoned_delegations()
now = time.time()
restored = 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 claim_event_delivery(evt: Dict[str, Any], consumer: str) -> Optional[str]:
"""Claim a durable delegation event; non-durable events need no token."""
if evt.get("type") != "async_delegation":
return ""
delegation_id = str(evt.get("delegation_id") or "")
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 drop_completion_delivery(delegation_id: str, claim_id: str) -> bool:
"""Terminally drop a claimed completion whose target is permanently gone.
Used when the spawning session ended at an explicit user boundary (/new,
reset) rather than a compression rotation. ``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:
if claim_id and evt.get("type") == "async_delegation":
complete_completion_delivery(str(evt.get("delegation_id") or ""), claim_id)
def release_event_delivery(evt: Dict[str, Any], claim_id: str) -> None:
if claim_id and evt.get("type") == "async_delegation":
release_completion_delivery(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()
if row is None:
return None
return {
"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, never shrink) the shared daemon executor.
In-flight futures keep running on a replaced pool until it is collected.
"""
global _executor, _executor_max_workers
with _executor_lock:
if _executor is None or max_workers > _executor_max_workers:
_executor = DaemonThreadPoolExecutor(
max_workers=max_workers, thread_name_prefix="async-delegate",
)
_executor_max_workers = max_workers
return _executor
def active_count() -> int:
"""Number of live async delegation UNITS (a whole batch counts as ONE slot)."""
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).
The truthful observability figure, unlike ``active_count()`` slots; a
batch with no goal list falls back to 1.
"""
def _size(r: Dict[str, Any]) -> int:
goals = r.get("goals") if r.get("is_batch") else None
return len(goals) if isinstance(goals, (list, tuple)) and goals else 1
with _records_lock:
return sum(_size(r) for r in _records.values() if r.get("status") in {"running", "finalizing"})
def _matches_session_selectors(
record: Dict[str, Any], *, session_key: str = "", origin_ui_session_id: str = "",
parent_session_id: str = "",
) -> bool:
return any(
wanted and str(record.get(field) or "") == wanted
for field, wanted in (
("origin_ui_session_id", origin_ui_session_id),
("session_key", session_key),
("parent_session_id", parent_session_id),
)
)
def _session_records_locked(statuses, session_key, origin_ui_session_id, parent_session_id):
"""Records in ``statuses`` matching any selector. Caller holds ``_records_lock``."""
return [
r for r in _records.values()
if r.get("status") in statuses
and _matches_session_selectors(
r,
session_key=session_key,
origin_ui_session_id=origin_ui_session_id,
parent_session_id=parent_session_id,
)
]
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."""
if not session_key and not origin_ui_session_id and not parent_session_id:
return False
with _records_lock:
return bool(_session_records_locked(_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"]
if len(completed) <= _MAX_RETAINED_COMPLETED:
return
completed.sort(key=lambda kv: kv[1].get("completed_at") or kv[1].get("dispatched_at") or 0)
for rid, _ in completed[: 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)`` moments before dispatch, so
the wake would self-post into the subagent's own session. The
request-scoped ``HERMES_SESSION_CHAT_ID`` binding (raw X-Hermes-Session-Id
on api_server) survives child construction; on push platforms chat_id is
a chat, not a session, so yield ``""`` there.
"""
try:
from gateway.session_context import get_session_env
if get_session_env("HERMES_SESSION_PLATFORM", "") != "api_server":
return ""
return get_session_env("HERMES_SESSION_CHAT_ID", "") or ""
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 []
if child_results and all(
(r.get("status") not in ("completed", "success")) for r in child_results
):
return "error"
return "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,
) -> 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.
"""
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,
# Stale-monitor bookkeeping (see _stale_monitor_loop).
"_progress_token": None,
"_progress_ts": dispatched_at,
"_interrupted_at": None,
}
with _records_lock:
running = sum(1 for r in _records.values() if r.get("status") in _ACTIVE_STATES)
if running >= max_async_children:
return {"status": "rejected", "error": capacity_error}
_records[delegation_id] = record
_persist_dispatch(record)
executor = _get_executor(max_async_children)
def _worker() -> None:
result: Dict[str, Any] = {}
status = "error"
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))
status = "error"
finally:
_finalize(delegation_id, result, status)
try:
# Propagate the dispatching profile so the detached child resolves
# get_hermes_home() under the right profile.
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)
_delete_durable_delegation(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"}`` at capacity.
"""
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} "
f"running). Wait for one to finish (its result will re-enter "
f"the chat), or run this task synchronously "
f"(background=false). Raise delegation.max_concurrent_children in "
f"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,
) -> Dict[str, Any]:
"""Dispatch a WHOLE fan-out batch as ONE background unit.
``runner`` runs the entire batch and returns the combined
``{"results": [...], "total_duration_seconds": N}`` dict. The batch
occupies ONE async slot (in-batch parallelism is bounded separately) and
produces a SINGLE completion event carrying the per-task ``results``.
"""
delegation_id = delegation_id or _new_delegation_id()
n = len(goals)
combined_goal = (
goals[0] if n == 1 else f"{n} parallel subagents: " + "; ".join(g[:40] for g in 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,
capacity_error=(
f"Async delegation capacity reached ({max_async_children} "
f"running). Wait for one to finish (its result will re-enter "
f"the chat), or raise delegation.max_concurrent_children in "
f"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: Dict[str, Any], status: str) -> None:
"""Mark a record complete and push its completion event onto the queue."""
event_record = _begin_finalization(delegation_id)
if event_record is None:
return
_push_completion_event(event_record, result, status)
_finish_finalization(delegation_id, status)
def _begin_finalization(delegation_id: str) -> Optional[Dict[str, Any]]:
"""Atomically claim terminal delivery; returns a record snapshot or None."""
with _records_lock:
record = _records.get(delegation_id)
if record is None or record.get("status") not in _ACTIVE_STATES:
return None
# Stay active until durable persistence and queue publication finish;
# otherwise process shutdown can kill this daemon worker in the gap
# after status flips but before SQLite is committed.
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
return dict(record)
def _finish_finalization(delegation_id: str, status: str) -> None:
with _records_lock:
record = _records.get(delegation_id)
if record is not None:
record["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 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:
# Batch payload: the full per-task results list (the formatter renders a
# consolidated block) plus live transcript paths, which persist after
# completion as the full-fidelity record of each child's run.
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"),
}
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")}),
}
_copy_truthy(record, evt, _ROUTING_KEYS)
_copy_present(result, evt, _STALL_META_KEYS)
_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)
# ── 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 _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 (a late
runner return is then ignored by ``_begin_finalization``).
"""
while not _monitor_stop.wait(_STALE_CHECK_INTERVAL):
now = time.time()
stalled: List[tuple] = [] # (delegation_id, quiet_for, in_tool)
expired: List[str] = [] # stalling past grace → force-finalize
any_monitorable = False
with _records_lock:
for record in _records.values():
status = record.get("status")
if status == "stalling":
any_monitorable = True
interrupted_at = record.get("_interrupted_at") or now
if now - interrupted_at >= _STALL_GRACE_SECONDS:
expired.append(record["delegation_id"])
continue
if status != "running":
continue
progress_fn = record.get("progress_fn")
if progress_fn is None:
continue
any_monitorable = True
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))
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:
record = _records.get(delegation_id)
fn = record.get("interrupt_fn") if record else None
if callable(fn):
try:
fn()
except Exception as exc:
logger.debug("Async delegation %s stall interrupt failed: %s", delegation_id, exc)
for delegation_id in expired:
_finalize_stalled(delegation_id)
if not any_monitorable:
return
def _finalize_stalled(delegation_id: str) -> None:
"""Force-finalize a stalling delegation whose runner never returned."""
event_record = _begin_finalization(delegation_id)
if event_record is None:
return
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"):
result = {**_batch_crash(error, duration), **stall_meta}
else:
result = {**_single_crash(error, duration), "status": "stalled", "exit_reason": "stalled", **stall_meta}
_push_completion_event(event_record, result, "stalled")
_finish_finalization(delegation_id, "stalled")
# ── 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 isinstance(part, (list, tuple)) and len(part) >= 2:
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)
else:
out.append(None)
return out
def list_async_delegations() -> List[Dict[str, Any]]:
"""Snapshot of async delegations (running + recently completed).
Excludes callables and private monitor bookkeeping; adds computed live
fields for UIs: ``seconds_since_progress``, ``children_activity`` /
``in_tool`` (sampled from ``progress_fn``), and stall context once the
monitor has tripped.
"""
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:
ts = r.get("_progress_ts")
if ts:
item["seconds_since_progress"] = round(now - ts, 1)
fn = r.get("progress_fn")
if callable(fn):
samplers[r["delegation_id"]] = 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) -> int:
"""Call ``interrupt_fn`` on each record; returns how many succeeded."""
count = 0
for r in targets:
fn = r.get("interrupt_fn")
if callable(fn):
try:
fn()
count += 1
except Exception as exc:
logger.debug("%s: %s interrupt failed: %s", caller, r.get("delegation_id"), exc)
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]
count = _interrupt_records(targets, "interrupt_all")
if count:
logger.info("Interrupted %d async delegation(s) (%s)", count, reason)
return count
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 matching 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). Returns how many.
"""
if not session_key and not origin_ui_session_id and not parent_session_id:
return 0
with _records_lock:
targets = _session_records_locked(_ACTIVE_STATES, session_key, origin_ui_session_id, parent_session_id)
count = _interrupt_records(targets, "interrupt_for_session")
if count:
logger.info("Interrupted %d async delegation(s) for ending session (%s)", count, reason)
return count
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()