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.
1169 lines
50 KiB
Python
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()
|