Merge remote-tracking branch 'origin/master' into p6137b

This commit is contained in:
nesquena-hermes
2026-07-17 01:09:51 +00:00
23 changed files with 1850 additions and 175 deletions
+163
View File
@@ -0,0 +1,163 @@
"""Fail-closed guard for in-process Hermes Agent source revisions.
Hermes WebUI currently imports ``run_agent.AIAgent`` into its long-lived server
process. If the Agent checkout changes while that process is alive, Python may
combine already-cached modules with newly-read source. Refuse to reuse that
mixed runtime and require a clean WebUI restart instead.
"""
from __future__ import annotations
from pathlib import Path
import sys
import subprocess
import threading
# Retain the discovered path as a diagnostic/test-visible compatibility value;
# runtime identity is deliberately captured from the loaded module below.
from api.config import _AGENT_DIR # noqa: F401
_RESTART_MESSAGE = (
"Hermes Agent was updated while Hermes WebUI was running. "
"Restart Hermes WebUI before retrying this action."
)
def _read_agent_revision(
agent_dir: Path | None,
*,
module_path: Path | None = None,
) -> str | None:
"""Return the loaded Agent checkout HEAD, or ``None`` if it is not tracked."""
if agent_dir is None:
return None
if module_path is None:
module = sys.modules.get("run_agent")
module_file = getattr(module, "__file__", None)
if not module_file:
return None
try:
module_path = Path(module_file).resolve()
except (OSError, RuntimeError, TypeError):
return None
try:
worktree_result = subprocess.run(
["git", "-C", str(agent_dir), "rev-parse", "--show-toplevel"],
check=False,
capture_output=True,
text=True,
timeout=2,
)
if worktree_result.returncode != 0:
return None
worktree = Path(worktree_result.stdout.strip()).resolve()
relative_module = module_path.relative_to(worktree).as_posix()
tracked_result = subprocess.run(
[
"git",
"--literal-pathspecs",
"-C",
str(worktree),
"ls-files",
"--error-unmatch",
"--",
relative_module,
],
check=False,
capture_output=True,
text=True,
timeout=2,
)
if tracked_result.returncode != 0:
return None
revision_result = subprocess.run(
["git", "-C", str(worktree), "rev-parse", "--verify", "HEAD"],
check=False,
capture_output=True,
text=True,
timeout=2,
)
except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError):
return None
revision = revision_result.stdout.strip()
return revision if revision_result.returncode == 0 and revision else None
_AGENT_SOURCE_DIR: Path | None = None
_AGENT_MODULE_PATH: Path | None = None
_AGENT_REVISION: str | None = None
_AIAgent = None
_RUNTIME_LOCK = threading.Lock()
class AgentRuntimeChangedError(RuntimeError):
"""Raised when the loaded Agent runtime no longer matches its source tree."""
def _loaded_agent_source_identity() -> tuple[Path, Path] | None:
"""Return the source directory and file that supplied ``run_agent``."""
module = sys.modules.get("run_agent")
module_file = getattr(module, "__file__", None)
if not module_file:
return None
try:
module_path = Path(module_file).resolve()
return module_path.parent, module_path
except (OSError, RuntimeError, TypeError):
return None
def _capture_loaded_agent_revision() -> None:
"""Bind the guard to the checkout that supplied the loaded Agent module."""
global _AGENT_SOURCE_DIR, _AGENT_MODULE_PATH, _AGENT_REVISION
if _AGENT_REVISION is not None:
ensure_agent_runtime_current()
return
identity = _loaded_agent_source_identity()
if identity is None:
return
source_dir, module_path = identity
current_revision = _read_agent_revision(source_dir, module_path=module_path)
_AGENT_SOURCE_DIR = source_dir
_AGENT_MODULE_PATH = module_path
_AGENT_REVISION = current_revision
def ensure_agent_runtime_current() -> None:
"""Reject a known Git checkout change instead of mixing Python modules."""
if _AGENT_REVISION is None:
return
if (
_read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH)
!= _AGENT_REVISION
):
raise AgentRuntimeChangedError(_RESTART_MESSAGE)
def require_ai_agent_class():
"""Import ``AIAgent`` after proving the loaded source revision is current."""
ensure_agent_runtime_current()
from run_agent import AIAgent # noqa: PLC0415
_capture_loaded_agent_revision()
return AIAgent
def get_ai_agent_class():
"""Return ``AIAgent`` while preserving the existing lazy-import retry."""
global _AIAgent, _AGENT_REVISION
with _RUNTIME_LOCK:
ensure_agent_runtime_current()
if _AIAgent is None:
try:
agent_class = require_ai_agent_class()
except ImportError:
return None
_AIAgent = agent_class
return _AIAgent
+17 -1
View File
@@ -63,7 +63,16 @@ _CLI_SESSIONS_CACHE_STREAMING_TTL_SECONDS = 45.0
_CLI_SESSIONS_CACHE_LOCK = threading.Lock()
_CLI_SESSIONS_CACHE_INFLIGHT: "dict[tuple, threading.Event]" = {}
_CLI_SESSIONS_CACHE_INVALIDATION_VERSION = 0
_CLI_SESSIONS_CACHE = {}
# LRU-bounded (drop-oldest) so a long-lived process under churn — where the
# state.db fingerprint advances on every streamed message and the structural
# clear-on-mutation listener doesn't fire for every fingerprint advance — can't
# accumulate orphaned heavy deepcopies. Each value is a copy.deepcopy() of the
# full CLI/cron session list (the expensive projection behind #4842/#4672), so
# the cap is deliberately small. TTL is still the primary freshness control;
# the cap is the backstop that the plain dict previously lacked. Mirrors the
# _CLAUDE_CODE_PARSE_CACHE / _SIDECAR_METADATA_CACHE LRU pattern.
_CLI_SESSIONS_CACHE: "collections.OrderedDict[tuple, tuple]" = collections.OrderedDict()
_CLI_SESSIONS_CACHE_MAX_ENTRIES = 8
_CLI_SESSIONS_CACHE_WAIT_SECONDS = 0.25
# Event waits that keep stale rows visible while a rebuild is in flight.
_CLI_SESSIONS_CACHE_STALE_WAIT_SECONDS = 0.10
@@ -6405,6 +6414,9 @@ def _cache_cli_sessions_if_current(
invalidation_stamp,
_copy_cli_sessions(sessions),
)
_CLI_SESSIONS_CACHE.move_to_end(cache_key)
while len(_CLI_SESSIONS_CACHE) > _CLI_SESSIONS_CACHE_MAX_ENTRIES:
_CLI_SESSIONS_CACHE.popitem(last=False)
return True
@@ -6423,6 +6435,8 @@ def _copy_fresh_cli_sessions_cache_entry(cache_key: tuple):
return None
if cached_expires_at <= time.monotonic():
return None
# LRU: a fresh hit is the most-recently-used entry.
_CLI_SESSIONS_CACHE.move_to_end(cache_key)
return _copy_cli_sessions(cached_sessions)
@@ -7272,6 +7286,8 @@ def get_cli_sessions(
if cached_stamp != _CLI_SESSIONS_CACHE_INVALIDATION_VERSION:
_CLI_SESSIONS_CACHE.pop(cache_key, None)
elif cached_expires_at > now:
# LRU: a fresh hit is the most-recently-used entry.
_CLI_SESSIONS_CACHE.move_to_end(cache_key)
return _copy_cli_sessions(cached_sessions)
else:
stale_sessions = _copy_cli_sessions(cached_sessions)
+145 -8
View File
@@ -34,6 +34,11 @@ from contextlib import closing
from urllib.parse import parse_qs, quote, unquote, urljoin, urlsplit
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
from api.agent_runtime import (
AgentRuntimeChangedError,
ensure_agent_runtime_current,
require_ai_agent_class,
)
from api.agent_sessions import (
MESSAGING_SOURCES,
_looks_like_default_cli_title,
@@ -16052,6 +16057,7 @@ def handle_post(handler, parsed) -> bool:
"api_key": _main_api_key,
}
ensure_agent_runtime_current()
try:
from agent.auxiliary_client import get_text_auxiliary_client
@@ -16072,7 +16078,7 @@ def handle_post(handler, parsed) -> bool:
except Exception as _e:
logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e)
from run_agent import AIAgent
AIAgent = require_ai_agent_class()
agent = AIAgent(
model=_main_model,
@@ -20425,6 +20431,9 @@ def _handle_btw(handler, body):
require(body, "question")
except ValueError as e:
return bad(handler, str(e))
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
if stale_response is not None:
return j(handler, stale_response, status=409)
if _session_is_subagent_view_only(str(body.get("session_id") or "")):
return bad(handler, "Subagent sessions are view-only and cannot be used for /btw from WebUI", 400)
try:
@@ -20484,6 +20493,9 @@ def _handle_background(handler, body):
require(body, "prompt")
except ValueError as e:
return bad(handler, str(e))
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
if stale_response is not None:
return j(handler, stale_response, status=409)
try:
s = get_session(body["session_id"])
except KeyError:
@@ -20770,6 +20782,31 @@ def _active_run_stream_for_session(session_id: str | None) -> str | None:
return None
def _agent_runtime_barrier_response(
*,
runner_local_owned: bool = False,
external_runtime_owned: bool | None = None,
) -> dict | None:
"""Return the typed stale-runtime response for local in-process turns."""
if external_runtime_owned is True:
return None
if runner_local_owned and webui_gateway_chat_enabled(get_config()):
return None
from api.runtime_adapter import runtime_adapter_runner_enabled
if runner_local_owned and runtime_adapter_runner_enabled():
return None
try:
ensure_agent_runtime_current()
except AgentRuntimeChangedError as exc:
return {
"error": str(exc),
"type": "agent_runtime_stale",
"retryable": True,
}
return None
def _start_chat_stream_for_session(
s,
*,
@@ -20783,8 +20820,18 @@ def _start_chat_stream_for_session(
goal_related: bool = False,
source: str = "webui",
moa_config=None,
external_runtime_owned: bool | None = None,
):
"""Persist pending state, register an SSE channel, and start an agent turn."""
if external_runtime_owned is None:
external_runtime_owned = webui_gateway_chat_enabled(get_config())
backend_is_gateway = bool(external_runtime_owned)
stale_response = _agent_runtime_barrier_response(
external_runtime_owned=backend_is_gateway,
)
if stale_response is not None:
stale_response["_status"] = 409
return stale_response
attachments = attachments or []
# Prevent duplicate runs in the same session while a stream is still active.
# This commonly happens after page refresh/reconnect races and can produce
@@ -20903,7 +20950,6 @@ def _start_chat_stream_for_session(
if goal_related:
STREAM_GOAL_RELATED[stream_id] = True
diag.stage("worker_thread_start") if diag else None
backend_is_gateway = webui_gateway_chat_enabled(get_config())
worker_target = _run_gateway_chat_streaming if backend_is_gateway else _run_agent_streaming
worker_kwargs = {"model_provider": model_provider, "goal_related": goal_related}
if moa_config and not backend_is_gateway:
@@ -21074,6 +21120,7 @@ def _start_run(
diag=diag,
source=source,
moa_config=moa_config,
external_runtime_owned=webui_gateway_chat_enabled(get_config()),
)
@@ -21167,6 +21214,10 @@ def start_session_turn(
msg = str(message or "").strip()
if not msg:
return {"error": "message is required", "_status": 400}
stale_response = _agent_runtime_barrier_response(runner_local_owned=True)
if stale_response is not None:
stale_response["_status"] = 409
return stale_response
turn_source = str(source or "process_wakeup").strip() or "process_wakeup"
try:
s = get_session(session_id)
@@ -21643,6 +21694,7 @@ def _handle_goal_command(handler, body):
model_provider=model_provider,
normalized_model=normalized_model,
goal_related=True,
external_runtime_owned=webui_gateway_chat_enabled(get_config()),
)
status = int(stream_response.pop("_status", 200) or 200)
payload.update(stream_response)
@@ -21661,6 +21713,12 @@ def _handle_chat_start(handler, body, diag=None):
require(body, "session_id")
except ValueError as e:
return bad(handler, str(e))
# Reject a stale local Agent runtime before materialising, claiming, or
# mutating any session state. Gateway-backed turns run in the gateway's
# process and do not depend on this WebUI process's imported checkout.
stale_response = _agent_runtime_barrier_response(runner_local_owned=True)
if stale_response is not None:
return j(handler, stale_response, status=409)
diag.stage("get_session") if diag else None
try:
s = _get_or_materialize_session(body["session_id"], refresh_cli_messages=True)
@@ -21956,6 +22014,9 @@ def _normalize_chat_attachments(raw_attachments):
def _handle_chat_sync(handler, body):
"""Fallback synchronous chat endpoint (POST /api/chat). Not used by frontend."""
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
if stale_response is not None:
return j(handler, stale_response, status=409)
if _session_is_subagent_view_only(str(body.get("session_id") or "")):
return bad(handler, "Subagent sessions are view-only and cannot be written from WebUI", 400)
s = get_session(body["session_id"])
@@ -21991,7 +22052,7 @@ def _handle_chat_sync(handler, body):
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = s.session_id
try:
from run_agent import AIAgent
AIAgent = require_ai_agent_class()
with CHAT_LOCK:
from api.config import (
@@ -22622,6 +22683,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
"base_url": _main_base_url,
"api_key": _main_api_key,
}
ensure_agent_runtime_current()
try:
from agent.auxiliary_client import get_text_auxiliary_client
@@ -22638,7 +22700,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
except Exception as _e:
logger.debug("git commit message auxiliary model failed; falling back to main model: %s", _e)
from run_agent import AIAgent
AIAgent = require_ai_agent_class()
agent = AIAgent(
model=_main_model,
@@ -22684,6 +22746,12 @@ def _handle_git_commit_message(handler, body):
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
except AgentRuntimeChangedError as e:
return j(handler, {
"error": str(e),
"type": "agent_runtime_stale",
"retryable": True,
}, status=409)
except Exception as e:
logger.exception("git commit message generation failed")
return bad(handler, _sanitize_error(e), 500)
@@ -22715,6 +22783,12 @@ def _handle_git_commit_message_selected(handler, body):
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
except AgentRuntimeChangedError as e:
return j(handler, {
"error": str(e),
"type": "agent_runtime_stale",
"retryable": True,
}, status=409)
except Exception as e:
logger.exception("selected git commit message generation failed")
return bad(handler, _sanitize_error(e), 500)
@@ -23739,6 +23813,10 @@ def _manual_compression_status_payload(job):
payload["ok"] = False
payload["error"] = job.get("error") or "Compression failed"
payload["error_status"] = int(job.get("error_status") or 400)
if job.get("error_type"):
payload["type"] = job["error_type"]
if job.get("retryable") is not None:
payload["retryable"] = bool(job["retryable"])
elif status == "cancelled":
payload["ok"] = False
payload["error"] = job.get("error") or "Compression cancelled"
@@ -23773,6 +23851,8 @@ def _run_manual_compression_job(sid, body):
"status": "error",
"error": str((payload or {}).get("error") or "Compression failed"),
"error_status": status,
"error_type": (payload or {}).get("type"),
"retryable": (payload or {}).get("retryable"),
"updated_at": now,
}
)
@@ -23784,6 +23864,21 @@ def _run_manual_compression_job(sid, body):
"updated_at": now,
}
)
except AgentRuntimeChangedError as exc:
logger.warning("Manual compression worker found stale Agent runtime for session %s", sid)
with _MANUAL_COMPRESSION_JOBS_LOCK:
job = _MANUAL_COMPRESSION_JOBS.get(sid)
if job:
job.update(
{
"status": "error",
"error": str(exc),
"error_status": 409,
"error_type": "agent_runtime_stale",
"retryable": True,
"updated_at": time.time(),
}
)
except Exception as exc:
logger.warning("Manual compression worker failed for session %s: %s", sid, exc)
with _MANUAL_COMPRESSION_JOBS_LOCK:
@@ -23820,6 +23915,34 @@ def _handle_session_compress_start(handler, body):
if focus_topic:
job_body["focus_topic"] = focus_topic
# Repeated start requests observe an existing running job and do not admit
# new Agent work, so preserve that idempotent read even if the checkout has
# since changed. Do not hold the job lock while running Git subprocesses.
now = time.time()
with _MANUAL_COMPRESSION_JOBS_LOCK:
_manual_compression_cleanup_locked(now)
existing = _MANUAL_COMPRESSION_JOBS.get(sid)
if existing:
existing_payload = _manual_compression_status_payload(existing)
if existing_payload.get("status") == "running":
return j(handler, existing_payload)
# Reject a stale local Agent runtime before creating the asynchronous job.
try:
ensure_agent_runtime_current()
except AgentRuntimeChangedError as exc:
return j(
handler,
{
"error": str(exc),
"type": "agent_runtime_stale",
"retryable": True,
},
status=409,
)
# Another start request may have admitted a job while the runtime check ran.
# Re-check under the lock so only one worker is created.
now = time.time()
with _MANUAL_COMPRESSION_JOBS_LOCK:
_manual_compression_cleanup_locked(now)
@@ -24035,10 +24158,11 @@ def _handle_session_compress(handler, body):
focus_topic,
)
ensure_agent_runtime_current()
import api.config as _cfg
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
import hermes_cli.runtime_provider as _runtime_provider
import run_agent as _run_agent
AIAgent = require_ai_agent_class()
resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(
_cfg.model_with_provider_context(s.model, getattr(s, "model_provider", None))
@@ -24081,7 +24205,7 @@ def _handle_session_compress(handler, body):
)
approx_tokens = _estimate_messages_tokens_rough(original_messages)
agent = _run_agent.AIAgent(
agent = AIAgent(
model=resolved_model,
provider=resolved_provider,
base_url=resolved_base_url,
@@ -24176,6 +24300,12 @@ def _handle_session_compress(handler, body):
"focus_topic": focus_topic,
},
)
except AgentRuntimeChangedError as e:
return j(handler, {
"error": str(e),
"type": "agent_runtime_stale",
"retryable": True,
}, status=409)
except Exception as e:
logger.warning("Manual session compression failed: %s", e)
return bad(handler, f"Compression failed: {_sanitize_error(e)}")
@@ -24685,10 +24815,11 @@ def _handle_handoff_summary(handler, body):
# Call LLM for summary.
try:
ensure_agent_runtime_current()
import api.config as _cfg
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
import hermes_cli.runtime_provider as _runtime_provider
import run_agent as _run_agent
AIAgent = require_ai_agent_class()
# Try to resolve model from an existing session, fall back to default.
resolved_model = None
@@ -24744,7 +24875,7 @@ def _handle_handoff_summary(handler, body):
"fallback": True,
})
agent = _run_agent.AIAgent(
agent = AIAgent(
model=resolved_model,
provider=resolved_provider,
base_url=resolved_base_url,
@@ -24824,6 +24955,12 @@ def _handle_handoff_summary(handler, body):
"rounds": rounds,
"fallback": fallback,
})
except AgentRuntimeChangedError as e:
return j(handler, {
"error": str(e),
"type": "agent_runtime_stale",
"retryable": True,
}, status=409)
except Exception as e:
logger.warning("Handoff summary generation failed: %s", e)
summary_text = _fallback_handoff_summary(msgs)
+109 -20
View File
@@ -564,10 +564,13 @@ def _prewarm_skill_tool_modules():
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
try:
from run_agent import AIAgent
except ImportError:
AIAgent = None
from api.agent_runtime import ensure_agent_runtime_current, get_ai_agent_class
# Eagerly attempt the import at startup, matching the pre-guard behavior. If
# dependencies are not ready yet, _get_ai_agent() retries when a chat starts.
AIAgent = get_ai_agent_class()
def _get_ai_agent():
"""Return AIAgent class, retrying the import if the initial attempt failed.
@@ -575,15 +578,13 @@ def _get_ai_agent():
auto_install_agent_deps() in server.py may install missing packages after
this module is first imported (common in Docker with a volume-mounted agent).
Re-attempting the import here picks up the newly installed packages without
requiring a server restart.
requiring a server restart. The shared runtime guard also refuses to reuse
cached Agent modules after the source checkout changes.
"""
global AIAgent
ensure_agent_runtime_current()
if AIAgent is None:
try:
from run_agent import AIAgent as _cls # noqa: PLC0415
AIAgent = _cls
except ImportError:
pass
AIAgent = get_ai_agent_class()
return AIAgent
@@ -6265,6 +6266,65 @@ def _last_resort_sync_from_core(session, stream_id, agent_lock):
)
def _session_db_is_open(session_db) -> bool:
"""True when *session_db* still has a live sqlite connection.
SessionDB.close() sets ``_conn = None``. Subagents capture the parent's
SessionDB object by reference at spawn time (delegate_tool), so closing
that object mid-parent-turn makes every subsequent child
``append_message`` fail with
``'NoneType' object has no attribute 'execute'``.
"""
if session_db is None:
return False
return getattr(session_db, "_conn", None) is not None
def _adopt_session_db_for_cached_agent(agent, new_session_db):
"""Attach a SessionDB to a reused cached agent without breaking subagents.
Historical behaviour (PR #1421 FD-leak fix): create a fresh SessionDB every
stream request and close the previous handle before replacing
``agent._session_db``. That stops EMFILE growth, but a server-side wakeup
/ new turn for the same parent session will close the shared handle while
background subagents are still writing into it.
Policy now:
- If the cached agent already holds an *open* SessionDB, keep it and close
the unused new handle (no FD leak; live subagents keep working).
- If the existing handle is missing or already closed, adopt *new_session_db*.
- If *new_session_db* is None, leave the existing handle alone.
"""
if agent is None:
return new_session_db
existing = getattr(agent, "_session_db", None)
if new_session_db is None:
return existing
if existing is new_session_db:
return existing
if _session_db_is_open(existing):
try:
new_session_db.close()
except Exception:
# Same observability as _replace_session_db_in_kwargs: a failed
# close here reintroduces the EMFILE pressure PR #1421 fixed.
logger.debug(
"Failed to close unused session_db handle in adopt helper",
exc_info=True,
)
return existing
if existing is not None:
try:
existing.close()
except Exception:
logger.debug(
"Failed to close previous session_db handle in adopt helper",
exc_info=True,
)
agent._session_db = new_session_db
return new_session_db
def _build_session_db_for_stream(state_db_path):
"""Build a per-request SessionDB handle for WebUI session search.
@@ -6280,12 +6340,36 @@ def _build_session_db_for_stream(state_db_path):
def _replace_session_db_in_kwargs(agent_kwargs, state_db_path):
"""Build a fresh SessionDB and replace ``agent_kwargs['session_db']`` safely."""
"""Build a fresh SessionDB and replace ``agent_kwargs['session_db']`` safely.
Does not close an existing open handle that may still be shared with live
subagents; only replaces when the prior handle is missing or already closed.
"""
if not isinstance(agent_kwargs, dict):
return None
_old_session_db = agent_kwargs.get("session_db")
_next_session_db = _build_session_db_for_stream(state_db_path)
if _next_session_db is None:
# Replacement construction failed. Keep the prior handle only if it is
# still open (live subagents may hold it by reference); otherwise
# degrade cleanly to None — as master did — so the rebuilt agent lazily
# reinitialises its SessionDB instead of reusing a closed handle and
# failing every persist/search with
# "'NoneType' object has no attribute 'execute'".
if _session_db_is_open(_old_session_db):
return _old_session_db
agent_kwargs["session_db"] = None
return None
if _session_db_is_open(_old_session_db):
# Keep the live handle; discard the unused new one.
try:
if _next_session_db is not _old_session_db:
_next_session_db.close()
except Exception:
logger.debug("Failed to close unused session_db handle during self-heal")
agent_kwargs["session_db"] = _old_session_db
return _old_session_db
if _old_session_db is not None and _old_session_db is not _next_session_db:
try:
_old_session_db.close()
@@ -7213,6 +7297,7 @@ def _run_agent_streaming(
logger.debug("register_process_session failed", exc_info=True)
# first-time module initialisation (which can be slow) does not
# block other concurrent sessions waiting on _ENV_LOCK (#2024).
ensure_agent_runtime_current()
_prewarm_skill_tool_modules()
_install_streaming_cronjob_profile_wrapper()
# Still set process-level env as fallback for tools that bypass thread-local
@@ -8295,15 +8380,19 @@ def _run_agent_streaming(
if 'prefill_messages' in _agent_kwargs and hasattr(agent, 'prefill_messages'):
agent.prefill_messages = list(_agent_kwargs.get('prefill_messages') or [])
if _session_db is not None:
# Close any previously held SessionDB connection before
# replacing it. Without this, each streaming request creates
# a new SessionDB whose WAL handles leak indefinitely,
# eventually causing EMFILE crashes (#streaming FD leak).
if hasattr(agent, '_session_db') and agent._session_db is not None:
try:
agent._session_db.close()
except Exception:
pass
# Prefer reusing a still-open SessionDB on the cached
# agent. Closing it mid-turn breaks background
# subagents that hold a reference to the same object
# (delegate_tool copies parent._session_db by ref) —
# they then fail with
# 'NoneType' object has no attribute 'execute'.
# When the existing handle is already closed/missing,
# adopt the fresh per-request SessionDB (and close the
# dead one) so we still avoid the EMFILE FD-leak from
# PR #1421.
_session_db = _adopt_session_db_for_cached_agent(
agent, _session_db
)
agent._session_db = _session_db
if hasattr(agent, '_api_call_count'):
agent._api_call_count = 0