refactor(run_agent): extract TurnFacadeMixin (run_conversation/chat admission wrapper)

This commit is contained in:
Teknium
2026-09-02 12:24:55 -07:00
parent f0526e6d74
commit 9576b49c0b
3 changed files with 614 additions and 589 deletions

610
agent/turn_facade.py Normal file
View File

@@ -0,0 +1,610 @@
"""``AIAgent.run_conversation`` / ``chat`` façade.
Turn admission around ``conversation_loop.run_conversation``: durable cross-process session turn lease +
refresher thread, turn-liveness watchdog, relay/accounting/portal scopes, and balanced start/finish marks.
Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged.
"""
import logging
import os
import threading
import uuid
from typing import Any, Dict, List, Optional
from agent.lazy_forward import forward as _forward
from tools.interrupt import set_interrupt as _set_interrupt
# Same logger name as the origin module so log records / caplog filters are unchanged.
logger = logging.getLogger("run_agent")
class TurnFacadeMixin:
"""run_conversation()/chat() (see module docstring)."""
def run_conversation(
self,
user_message: Any,
system_message: str = None,
conversation_history: List[Dict[str, Any]] = None,
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
persist_user_display_kind: Optional[str] = None,
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
persist_user_platform_id: Optional[str] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Forwarder — see ``agent.conversation_loop.run_conversation``."""
# A review shares this session_id for cache parity: fence review startup or interrupt an admitted
# request and await its exit before opening live-turn instrumentation (#84423).
from agent.background_review import cancel_background_review_for_live_turn
cancel_background_review_for_live_turn(self)
# Turn liveness for the deferred-review idle queue; start-mark is the first statement of the try
# so the finally's note_turn_finished balances every exit.
from agent.review_idle_queue import QUEUE as _review_queue
from agent.aux_accounting import (
reset_accounting_context,
set_accounting_context,
)
from agent import relay_runtime
from agent.conversation_loop import run_conversation
from agent.portal_tags import (
reset_affinity_scope,
reset_conversation_context,
set_affinity_scope,
set_conversation_context,
)
from agent.prompt_cache_scope import declared_conversation_scope_safe
from hermes_cli.observability.relay_shared_metrics import (
finish_task_run,
start_task_run,
)
from agent.subagent_lifecycle import bind_subagent_parent
effective_task_id = task_id or str(uuid.uuid4())
session_id = str(getattr(self, "session_id", None) or "")
task_context = {
"session_id": session_id,
"task_id": effective_task_id,
"platform": getattr(self, "platform", None) or "",
}
relay_turn_id = (
f"{session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
)
self._relay_pending_turn_id = relay_turn_id
relay_parent_session_id = (
str(getattr(self, "_parent_session_id", None) or "")
if task_context["platform"] == "subagent"
else ""
)
relay_lease = None
relay_turn = None
durable_turn_lease = None
durable_turn_lease_stop = None
durable_turn_lease_thread = None
durable_turn_liveness_thread = None
durable_turn_lease_activity_lock = threading.Lock()
durable_turn_lease_turn_active = False
durable_turn_lease_interrupt_message = None
token = None
# Initialized alongside `token`: early returns leave the try before set_affinity_scope() and the
# finally reads this unconditionally (PR #97158).
affinity_token = None
acct_token = None
task_started = False
task_finished = False
relay_outcome = "failed"
def _stop_durable_turn_lease_refresher() -> None:
nonlocal durable_turn_lease_turn_active
with durable_turn_lease_activity_lock:
durable_turn_lease_turn_active = False
if durable_turn_lease_stop is not None:
durable_turn_lease_stop.set()
def _clear_durable_turn_lease_interrupt() -> None:
"""Clear only the interrupt admitted by this turn's refresher."""
message = durable_turn_lease_interrupt_message
if not message:
return
def _clear_if_owned() -> None:
if getattr(self, "_interrupt_message", None) != message:
return
self._interrupt_requested = False
self._interrupt_message = None
getattr(self, "_hard_interrupt_requested", threading.Event()).clear()
self._interrupt_thread_signal_pending = False
if self._execution_thread_id is not None:
_set_interrupt(False, self._execution_thread_id)
redirect_lock = getattr(self, "_pending_redirect_lock", None)
if redirect_lock is None:
_clear_if_owned()
else:
with redirect_lock:
_clear_if_owned()
try:
_review_queue.note_turn_started()
# Durable cross-process lease over load -> run -> flush (Desktop, CLI resume, gateway, background
# delivery sharing state.db, #84234).
_turn_db = getattr(self, "_session_db", None)
_durable_session_exists = False
if _turn_db is not None and session_id:
try:
_durable_session_exists = _turn_db.get_session(session_id) is not None
except Exception:
# A locked / non-WAL read is not proof the row is absent; treating probe failure as
# "fresh" ran fail-open at the exact contention point (#84234). Acquire, or fail closed.
logger.warning(
"Could not check durable session before turn lease; "
"will acquire rather than run without serialization",
exc_info=True,
)
_durable_session_exists = True
if (
_turn_db is not None
and session_id
and not getattr(self, "_persist_disabled", False)
# A fresh session id has no durable transcript to race over, and callers may supply an in-
# memory seed before the row exists — reloading would erase it.
and _durable_session_exists
# Check the concrete type: MagicMock-style shims accept any attribute without the protocol.
and callable(
getattr(type(_turn_db), "acquire_session_turn_lease", None)
)
):
# Row proven to exist — suppress the redundant create attempt.
self._session_db_created = True
_durable_holder = (
f"pid={os.getpid()}:turn={relay_turn_id}:platform="
f"{task_context['platform'] or 'unknown'}"
)
_lease_ttl = 300.0
_lease_waited = False
def _on_session_turn_lease_wait(elapsed: float) -> None:
nonlocal _lease_waited
_lease_waited = True
if elapsed < 1.0:
self._emit_status(
"⏳ Another Hermes process is using this session; "
"waiting for it to finish before starting your turn..."
)
else:
self._emit_status(
"⏳ Still waiting for the other Hermes process on "
f"this session ({int(elapsed)}s)..."
)
if not _turn_db.acquire_session_turn_lease(
session_id,
_durable_holder,
ttl_seconds=_lease_ttl,
wait_seconds=1800.0,
on_wait=_on_session_turn_lease_wait,
should_abort=lambda: getattr(self, "_interrupt_requested", False),
):
if getattr(self, "_interrupt_requested", False):
logger.info(
"session turn lease wait aborted by interrupt: %s",
session_id,
)
relay_outcome = "cancelled"
interrupt_msg = (
"Stopped waiting for another Hermes process on "
"this session. Your message was not processed."
)
interrupt_result = {
"final_response": interrupt_msg,
"messages": list(conversation_history or []),
"api_calls": 0,
"completed": False,
"interrupted": True,
}
interrupt_message = getattr(
self, "_interrupt_message", None
)
if interrupt_message:
interrupt_result["interrupt_message"] = (
interrupt_message
)
# The finalizer never runs on this early return; clear so a cached agent doesn't fail-
# close the next turn.
try:
self.clear_interrupt()
except Exception:
self._interrupt_requested = False
self._interrupt_message = None
return interrupt_result
# Fail closed like gateway TurnLeaseTimeoutError: surface a resend notice, not a bare
# TimeoutError.
timeout_msg = (
"⏳ Another Hermes process kept this session busy too "
"long. Your message was not processed - wait for the "
"other process to finish, then send it again."
)
logger.error(
"session turn lease wait timed out for %s",
session_id,
)
try:
self._emit_warning(timeout_msg)
except Exception:
logger.debug(
"Failed to emit session turn lease timeout warning",
exc_info=True,
)
relay_outcome = "timed_out"
return {
"final_response": timeout_msg,
"messages": list(conversation_history or []),
"api_calls": 0,
"completed": False,
"failed": True,
"error": f"session_turn_lease_timeout:{session_id}",
}
# Assign only after admission so the finally cannot release a holder that never owned the row;
# persist paths read the agent attr so a late flush is fenced in the same SQLite transaction.
durable_turn_lease = _durable_holder
self._active_session_turn_lease_holder = _durable_holder
self._active_session_turn_lease_ttl_seconds = _lease_ttl
if _lease_waited:
self._emit_status(
"Session is free; loading the latest transcript..."
)
# The holder may have compressed/rotated the session while we waited: reload only AFTER
# admission, and skip when acquisition was immediate (avoids a needless prompt-cache miss).
if _lease_waited:
latest_session_id = _turn_db.resolve_resume_session_id(session_id)
if latest_session_id:
self.session_id = latest_session_id
task_context["session_id"] = latest_session_id
conversation_history = _turn_db.get_messages_as_conversation(
self.session_id,
repair_alternation=True,
include_row_ids=True,
)
# Long turns outlive a fixed TTL: refresh in a daemon thread; holder-qualified UPDATE/DELETE
# fence a late refresher from a successor lease.
durable_turn_lease_stop = threading.Event()
_lease_refresh_interval = float(
getattr(self, "_session_turn_lease_refresh_interval", 60.0)
)
# ── Turn liveness watchdog (#95548) ──
# Lease renewal is NOT evidence of progress; a silently stalled turn would renew forever.
# Policy
# lives in agent/turn_liveness.py; this block only wires config + commit/deactivate callbacks.
try:
from hermes_cli.config import (
load_config_readonly as _liveness_load_config,
)
_liveness_config = _liveness_load_config() or {}
except Exception:
_liveness_config = {}
from agent import turn_liveness
_liveness_timeout, _liveness_poll = (
turn_liveness.resolve_turn_liveness_settings(_liveness_config)
)
def _interrupt_turn(message: str) -> None:
# Lease-loss interrupts fire UNCONDITIONALLY (no generation claim): a lost lease means
# this process no longer owns the session. Only the watchdog's stalls can be spuriously
# stale.
nonlocal durable_turn_lease_interrupt_message
with durable_turn_lease_activity_lock:
if (
durable_turn_lease_stop.is_set()
or not durable_turn_lease_turn_active
):
return
durable_turn_lease_interrupt_message = message
try:
self.interrupt(message, hard_cancel=True)
except Exception:
self._interrupt_requested = True
self._interrupt_message = message
def _commit_turn_liveness_abort(
snapshot: "turn_liveness.ActivitySnapshot",
message: str,
) -> bool:
"""Commit point for the watchdog's stall observation.
Revalidates the observed ``(generation, timestamp)`` under the SAME lock
``_touch_activity`` uses, so a turn
that resumed while the stall was logged is never hard-cancelled. The revalidated
generation is carried into
``interrupt`` as ``require_generation``, which consumes it with the first publication in
ONE critical section.
If ``interrupt`` raises, the abort declines FAIL-CLOSED. Returns False when stale or
already winding down.
"""
nonlocal durable_turn_lease_interrupt_message
with self._liveness_activity_lock():
current_generation = getattr(
self, "_turn_liveness_activity_generation", 0
)
if (
current_generation,
getattr(self, "_last_activity_ts", None),
) != (snapshot.generation, snapshot.activity_ts):
return False
with durable_turn_lease_activity_lock:
if (
durable_turn_lease_stop.is_set()
or not durable_turn_lease_turn_active
):
return False
try:
published = self.interrupt(
message,
hard_cancel=True,
require_generation=current_generation,
)
except Exception:
# Round-4 (#95663): fail closed — an exceptional path must not turn an unvalidated
# claim into unconditional abort authority.
logger.debug(
"Turn liveness abort interrupt raised; "
"declining the abort",
exc_info=True,
)
published = False
if published is False:
# Claim went stale between revalidation and the hammer: real progress landed, abandon
# the abort.
return False
with durable_turn_lease_activity_lock:
durable_turn_lease_interrupt_message = message
return True
def _deactivate_turn_after_liveness_abort() -> None:
"""Stop lease renewal after a committed liveness abort.
A wedge the hard interrupt cannot unwind must not keep the lease alive forever; TTL expiry
lets stale-turn cleanup reclaim the row.
"""
nonlocal durable_turn_lease_turn_active
with durable_turn_lease_activity_lock:
durable_turn_lease_stop.set()
durable_turn_lease_turn_active = False
def _turn_is_active() -> bool:
with durable_turn_lease_activity_lock:
return durable_turn_lease_turn_active
def _refresh_durable_turn_lease() -> None:
while not durable_turn_lease_stop.wait(_lease_refresh_interval):
try:
if not _turn_db.refresh_session_turn_lease(
getattr(self, "session_id", None) or session_id,
durable_turn_lease,
ttl_seconds=_lease_ttl,
):
# finally sets stop then releases; a late holder-fenced miss must not hard-
# interrupt the next turn.
if durable_turn_lease_stop.is_set():
return
logger.error(
"Lost session turn lease while turn is active: %s",
getattr(self, "session_id", None) or session_id,
)
_interrupt_turn(
"Session turn lease lost; stopping to protect "
"the transcript."
)
return
except Exception:
if durable_turn_lease_stop.is_set():
return
logger.warning(
"Failed to refresh session turn lease: %s",
getattr(self, "session_id", None) or session_id,
exc_info=True,
)
_interrupt_turn(
"Session turn lease could not be refreshed; "
"stopping to protect the transcript."
)
return
durable_turn_lease_thread = threading.Thread(
target=_refresh_durable_turn_lease,
name="session-turn-lease-refresh",
daemon=True,
)
if _liveness_timeout is not None:
durable_turn_liveness_thread = (
turn_liveness.TurnLivenessWatchdog(
self,
session_id=getattr(self, "session_id", None) or session_id,
timeout_s=_liveness_timeout,
poll_s=_liveness_poll,
stop_event=durable_turn_lease_stop,
activity_lock=self._liveness_activity_lock(),
is_turn_active=_turn_is_active,
commit_abort=_commit_turn_liveness_abort,
deactivate_turn=_deactivate_turn_after_liveness_abort,
).make_thread()
)
relay_lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation(
profile_key=relay_runtime.current_profile_key(),
session_id=task_context["session_id"],
platform=task_context["platform"],
parent_session_id=relay_parent_session_id,
model=str(getattr(self, "model", None) or ""),
)
relay_turn = relay_runtime.SESSION_COORDINATOR.begin_turn(
relay_lease,
turn_id=relay_turn_id,
task_id=effective_task_id,
)
# Keep existing tests and external relay-runtime shims that return
# a minimal turn object compatible with the new opt-out flag.
if getattr(relay_turn, "relay_enabled", True):
start_task_run(
**task_context,
parent_session_id=getattr(self, "_parent_session_id", None) or "",
)
task_started = True
# Publish the conversation id for ambient Nous Portal tagging: every LLM call in this turn (loop,
# compression, vision, MoA, review forks) inherits `conversation=<root>`.
token = set_conversation_context(self._conversation_root_id())
# Routing/affinity scope the HOST declared; providers fall back to the id above when unset
# (#96811).
affinity_token = set_affinity_scope(
declared_conversation_scope_safe(self)
)
# Publish session accounting handles so auxiliary calls record usage into session_model_usage
# (#23270).
acct_token = set_accounting_context(
getattr(self, "_session_db", None),
getattr(self, "session_id", None),
)
from agent.auxiliary_client import scoped_runtime_main
# Keep the ContextVar scope local (tokens on the agent may be observed from another thread).
with bind_subagent_parent(self), scoped_runtime_main({}):
try:
if durable_turn_lease_thread is not None:
with durable_turn_lease_activity_lock:
durable_turn_lease_turn_active = True
# Stamp the activity clock at turn entry (#95663): `_last_activity_ts` persists across
# turns, so without this the watchdog would measure idle from the PREVIOUS turn and
# abort a fresh one.
self._touch_activity("starting new turn")
durable_turn_lease_thread.start()
if durable_turn_liveness_thread is not None:
durable_turn_liveness_thread.start()
result = run_conversation(
self,
user_message,
system_message,
conversation_history,
effective_task_id,
stream_callback,
persist_user_message,
persist_user_timestamp=persist_user_timestamp,
persist_user_display_kind=persist_user_display_kind,
persist_user_display_metadata=persist_user_display_metadata,
persist_user_platform_id=persist_user_platform_id,
moa_config=moa_config,
)
finally:
# Post-loop relay/task finalization must not receive a late refresh interrupt.
_stop_durable_turn_lease_refresher()
# Interrupt clear is deferred until after thread join (outer finally) so a refresher
# firing between stop and join cannot leave an interrupt behind.
terminal = result if isinstance(result, dict) else {}
if terminal.get("interrupted") is True:
relay_outcome = "cancelled"
elif terminal.get("failed") is True:
relay_outcome = "failed"
else:
relay_outcome = "success"
relay_runtime.SESSION_COORDINATOR.finish_logical_calls(
relay_turn,
outcome=relay_outcome,
)
if task_started:
task_finished = True
finish_task_run(**task_context, result=result)
return result
except BaseException as exc:
if isinstance(exc, (KeyboardInterrupt, InterruptedError)) or (
type(exc).__name__ == "CancelledError"
):
relay_outcome = "cancelled"
elif isinstance(exc, TimeoutError):
relay_outcome = "timed_out"
if relay_turn is not None:
relay_runtime.SESSION_COORDINATOR.finish_logical_calls(
relay_turn,
outcome=relay_outcome,
)
if task_started and not task_finished:
task_finished = True
finish_task_run(**task_context, error=exc)
raise
finally:
try:
if relay_turn is not None:
relay_runtime.SESSION_COORDINATOR.end_turn(
relay_turn,
outcome=relay_outcome,
)
finally:
try:
if relay_lease is not None:
relay_runtime.SESSION_COORDINATOR.release_conversation(
relay_lease
)
finally:
_stop_durable_turn_lease_refresher()
for _durable_thread in (
durable_turn_lease_thread,
durable_turn_liveness_thread,
):
if (
_durable_thread is not None
and _durable_thread.is_alive()
):
_durable_thread.join(timeout=1.0)
# Clear any refresher interrupt fired between stop and join; must run AFTER join.
_clear_durable_turn_lease_interrupt()
if durable_turn_lease is not None:
try:
_turn_db.release_session_turn_lease(
session_id, durable_turn_lease
)
except Exception:
logger.error(
"Failed to release session turn lease: %s",
session_id,
exc_info=True,
)
if (
getattr(self, "_active_session_turn_lease_holder", None)
== durable_turn_lease
):
self._active_session_turn_lease_holder = None
self._active_session_turn_lease_ttl_seconds = None
# Always clear mid-turn labels when the turn exits — including
# interrupted early returns that skip finalize_turn. Keep ts.
try:
self._reset_activity_labels_after_turn()
except Exception:
pass
if getattr(self, "_relay_pending_turn_id", None) == relay_turn_id:
self._relay_pending_turn_id = None
if acct_token is not None:
reset_accounting_context(acct_token)
if token is not None:
reset_conversation_context(token)
if affinity_token is not None:
reset_affinity_scope(affinity_token)
# Balance note_turn_started on every exit so the idle queue's live-turn count cannot leak.
try:
_review_queue.note_turn_finished()
except Exception:
pass
def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:
"""Simple chat interface that returns just the final response string.
``stream_callback`` is invoked with each text delta during streaming.
"""
result = self.run_conversation(message, stream_callback=stream_callback)
return result["final_response"]
_run_codex_app_server_turn = _forward("agent.codex_runtime", "run_codex_app_server_turn")

View File

@@ -186,6 +186,7 @@ from agent.session_persistence import ( # noqa: F401 # re-exported: cli/gatewa
_safe_session_filename_component,
)
from agent.compression_facade import CompressionFacadeMixin
from agent.turn_facade import TurnFacadeMixin
from agent.lazy_forward import forward as _forward, forward_static as _forward_static
from agent.session_activity import ActivityProvenance
from agent.model_metadata import (
@@ -313,6 +314,7 @@ class AIAgent(
RateLimitCreditsMixin,
SessionPersistenceMixin,
CompressionFacadeMixin,
TurnFacadeMixin,
):
"""AI Agent with tool calling capabilities."""
@@ -2790,595 +2792,6 @@ class AIAgent(
logger.debug("Conversation root lineage walk failed", exc_info=True)
return start
def run_conversation(
self,
user_message: Any,
system_message: str = None,
conversation_history: List[Dict[str, Any]] = None,
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
persist_user_display_kind: Optional[str] = None,
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
persist_user_platform_id: Optional[str] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Forwarder — see ``agent.conversation_loop.run_conversation``."""
# A review shares this session_id for cache parity: fence review startup or interrupt an admitted
# request and await its exit before opening live-turn instrumentation (#84423).
from agent.background_review import cancel_background_review_for_live_turn
cancel_background_review_for_live_turn(self)
# Turn liveness for the deferred-review idle queue; start-mark is the first statement of the try
# so the finally's note_turn_finished balances every exit.
from agent.review_idle_queue import QUEUE as _review_queue
from agent.aux_accounting import (
reset_accounting_context,
set_accounting_context,
)
from agent import relay_runtime
from agent.conversation_loop import run_conversation
from agent.portal_tags import (
reset_affinity_scope,
reset_conversation_context,
set_affinity_scope,
set_conversation_context,
)
from agent.prompt_cache_scope import declared_conversation_scope_safe
from hermes_cli.observability.relay_shared_metrics import (
finish_task_run,
start_task_run,
)
from agent.subagent_lifecycle import bind_subagent_parent
effective_task_id = task_id or str(uuid.uuid4())
session_id = str(getattr(self, "session_id", None) or "")
task_context = {
"session_id": session_id,
"task_id": effective_task_id,
"platform": getattr(self, "platform", None) or "",
}
relay_turn_id = (
f"{session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
)
self._relay_pending_turn_id = relay_turn_id
relay_parent_session_id = (
str(getattr(self, "_parent_session_id", None) or "")
if task_context["platform"] == "subagent"
else ""
)
relay_lease = None
relay_turn = None
durable_turn_lease = None
durable_turn_lease_stop = None
durable_turn_lease_thread = None
durable_turn_liveness_thread = None
durable_turn_lease_activity_lock = threading.Lock()
durable_turn_lease_turn_active = False
durable_turn_lease_interrupt_message = None
token = None
# Initialized alongside `token`: early returns leave the try before set_affinity_scope() and the
# finally reads this unconditionally (PR #97158).
affinity_token = None
acct_token = None
task_started = False
task_finished = False
relay_outcome = "failed"
def _stop_durable_turn_lease_refresher() -> None:
nonlocal durable_turn_lease_turn_active
with durable_turn_lease_activity_lock:
durable_turn_lease_turn_active = False
if durable_turn_lease_stop is not None:
durable_turn_lease_stop.set()
def _clear_durable_turn_lease_interrupt() -> None:
"""Clear only the interrupt admitted by this turn's refresher."""
message = durable_turn_lease_interrupt_message
if not message:
return
def _clear_if_owned() -> None:
if getattr(self, "_interrupt_message", None) != message:
return
self._interrupt_requested = False
self._interrupt_message = None
getattr(self, "_hard_interrupt_requested", threading.Event()).clear()
self._interrupt_thread_signal_pending = False
if self._execution_thread_id is not None:
_set_interrupt(False, self._execution_thread_id)
redirect_lock = getattr(self, "_pending_redirect_lock", None)
if redirect_lock is None:
_clear_if_owned()
else:
with redirect_lock:
_clear_if_owned()
try:
_review_queue.note_turn_started()
# Durable cross-process lease over load -> run -> flush (Desktop, CLI resume, gateway, background
# delivery sharing state.db, #84234).
_turn_db = getattr(self, "_session_db", None)
_durable_session_exists = False
if _turn_db is not None and session_id:
try:
_durable_session_exists = _turn_db.get_session(session_id) is not None
except Exception:
# A locked / non-WAL read is not proof the row is absent; treating probe failure as
# "fresh" ran fail-open at the exact contention point (#84234). Acquire, or fail closed.
logger.warning(
"Could not check durable session before turn lease; "
"will acquire rather than run without serialization",
exc_info=True,
)
_durable_session_exists = True
if (
_turn_db is not None
and session_id
and not getattr(self, "_persist_disabled", False)
# A fresh session id has no durable transcript to race over, and callers may supply an in-
# memory seed before the row exists — reloading would erase it.
and _durable_session_exists
# Check the concrete type: MagicMock-style shims accept any attribute without the protocol.
and callable(
getattr(type(_turn_db), "acquire_session_turn_lease", None)
)
):
# Row proven to exist — suppress the redundant create attempt.
self._session_db_created = True
_durable_holder = (
f"pid={os.getpid()}:turn={relay_turn_id}:platform="
f"{task_context['platform'] or 'unknown'}"
)
_lease_ttl = 300.0
_lease_waited = False
def _on_session_turn_lease_wait(elapsed: float) -> None:
nonlocal _lease_waited
_lease_waited = True
if elapsed < 1.0:
self._emit_status(
"⏳ Another Hermes process is using this session; "
"waiting for it to finish before starting your turn..."
)
else:
self._emit_status(
"⏳ Still waiting for the other Hermes process on "
f"this session ({int(elapsed)}s)..."
)
if not _turn_db.acquire_session_turn_lease(
session_id,
_durable_holder,
ttl_seconds=_lease_ttl,
wait_seconds=1800.0,
on_wait=_on_session_turn_lease_wait,
should_abort=lambda: getattr(self, "_interrupt_requested", False),
):
if getattr(self, "_interrupt_requested", False):
logger.info(
"session turn lease wait aborted by interrupt: %s",
session_id,
)
relay_outcome = "cancelled"
interrupt_msg = (
"Stopped waiting for another Hermes process on "
"this session. Your message was not processed."
)
interrupt_result = {
"final_response": interrupt_msg,
"messages": list(conversation_history or []),
"api_calls": 0,
"completed": False,
"interrupted": True,
}
interrupt_message = getattr(
self, "_interrupt_message", None
)
if interrupt_message:
interrupt_result["interrupt_message"] = (
interrupt_message
)
# The finalizer never runs on this early return; clear so a cached agent doesn't fail-
# close the next turn.
try:
self.clear_interrupt()
except Exception:
self._interrupt_requested = False
self._interrupt_message = None
return interrupt_result
# Fail closed like gateway TurnLeaseTimeoutError: surface a resend notice, not a bare
# TimeoutError.
timeout_msg = (
"⏳ Another Hermes process kept this session busy too "
"long. Your message was not processed - wait for the "
"other process to finish, then send it again."
)
logger.error(
"session turn lease wait timed out for %s",
session_id,
)
try:
self._emit_warning(timeout_msg)
except Exception:
logger.debug(
"Failed to emit session turn lease timeout warning",
exc_info=True,
)
relay_outcome = "timed_out"
return {
"final_response": timeout_msg,
"messages": list(conversation_history or []),
"api_calls": 0,
"completed": False,
"failed": True,
"error": f"session_turn_lease_timeout:{session_id}",
}
# Assign only after admission so the finally cannot release a holder that never owned the row;
# persist paths read the agent attr so a late flush is fenced in the same SQLite transaction.
durable_turn_lease = _durable_holder
self._active_session_turn_lease_holder = _durable_holder
self._active_session_turn_lease_ttl_seconds = _lease_ttl
if _lease_waited:
self._emit_status(
"Session is free; loading the latest transcript..."
)
# The holder may have compressed/rotated the session while we waited: reload only AFTER
# admission, and skip when acquisition was immediate (avoids a needless prompt-cache miss).
if _lease_waited:
latest_session_id = _turn_db.resolve_resume_session_id(session_id)
if latest_session_id:
self.session_id = latest_session_id
task_context["session_id"] = latest_session_id
conversation_history = _turn_db.get_messages_as_conversation(
self.session_id,
repair_alternation=True,
include_row_ids=True,
)
# Long turns outlive a fixed TTL: refresh in a daemon thread; holder-qualified UPDATE/DELETE
# fence a late refresher from a successor lease.
durable_turn_lease_stop = threading.Event()
_lease_refresh_interval = float(
getattr(self, "_session_turn_lease_refresh_interval", 60.0)
)
# ── Turn liveness watchdog (#95548) ──
# Lease renewal is NOT evidence of progress; a silently stalled turn would renew forever.
# Policy
# lives in agent/turn_liveness.py; this block only wires config + commit/deactivate callbacks.
try:
from hermes_cli.config import (
load_config_readonly as _liveness_load_config,
)
_liveness_config = _liveness_load_config() or {}
except Exception:
_liveness_config = {}
from agent import turn_liveness
_liveness_timeout, _liveness_poll = (
turn_liveness.resolve_turn_liveness_settings(_liveness_config)
)
def _interrupt_turn(message: str) -> None:
# Lease-loss interrupts fire UNCONDITIONALLY (no generation claim): a lost lease means
# this process no longer owns the session. Only the watchdog's stalls can be spuriously
# stale.
nonlocal durable_turn_lease_interrupt_message
with durable_turn_lease_activity_lock:
if (
durable_turn_lease_stop.is_set()
or not durable_turn_lease_turn_active
):
return
durable_turn_lease_interrupt_message = message
try:
self.interrupt(message, hard_cancel=True)
except Exception:
self._interrupt_requested = True
self._interrupt_message = message
def _commit_turn_liveness_abort(
snapshot: "turn_liveness.ActivitySnapshot",
message: str,
) -> bool:
"""Commit point for the watchdog's stall observation.
Revalidates the observed ``(generation, timestamp)`` under the SAME lock
``_touch_activity`` uses, so a turn
that resumed while the stall was logged is never hard-cancelled. The revalidated
generation is carried into
``interrupt`` as ``require_generation``, which consumes it with the first publication in
ONE critical section.
If ``interrupt`` raises, the abort declines FAIL-CLOSED. Returns False when stale or
already winding down.
"""
nonlocal durable_turn_lease_interrupt_message
with self._liveness_activity_lock():
current_generation = getattr(
self, "_turn_liveness_activity_generation", 0
)
if (
current_generation,
getattr(self, "_last_activity_ts", None),
) != (snapshot.generation, snapshot.activity_ts):
return False
with durable_turn_lease_activity_lock:
if (
durable_turn_lease_stop.is_set()
or not durable_turn_lease_turn_active
):
return False
try:
published = self.interrupt(
message,
hard_cancel=True,
require_generation=current_generation,
)
except Exception:
# Round-4 (#95663): fail closed — an exceptional path must not turn an unvalidated
# claim into unconditional abort authority.
logger.debug(
"Turn liveness abort interrupt raised; "
"declining the abort",
exc_info=True,
)
published = False
if published is False:
# Claim went stale between revalidation and the hammer: real progress landed, abandon
# the abort.
return False
with durable_turn_lease_activity_lock:
durable_turn_lease_interrupt_message = message
return True
def _deactivate_turn_after_liveness_abort() -> None:
"""Stop lease renewal after a committed liveness abort.
A wedge the hard interrupt cannot unwind must not keep the lease alive forever; TTL expiry
lets stale-turn cleanup reclaim the row.
"""
nonlocal durable_turn_lease_turn_active
with durable_turn_lease_activity_lock:
durable_turn_lease_stop.set()
durable_turn_lease_turn_active = False
def _turn_is_active() -> bool:
with durable_turn_lease_activity_lock:
return durable_turn_lease_turn_active
def _refresh_durable_turn_lease() -> None:
while not durable_turn_lease_stop.wait(_lease_refresh_interval):
try:
if not _turn_db.refresh_session_turn_lease(
getattr(self, "session_id", None) or session_id,
durable_turn_lease,
ttl_seconds=_lease_ttl,
):
# finally sets stop then releases; a late holder-fenced miss must not hard-
# interrupt the next turn.
if durable_turn_lease_stop.is_set():
return
logger.error(
"Lost session turn lease while turn is active: %s",
getattr(self, "session_id", None) or session_id,
)
_interrupt_turn(
"Session turn lease lost; stopping to protect "
"the transcript."
)
return
except Exception:
if durable_turn_lease_stop.is_set():
return
logger.warning(
"Failed to refresh session turn lease: %s",
getattr(self, "session_id", None) or session_id,
exc_info=True,
)
_interrupt_turn(
"Session turn lease could not be refreshed; "
"stopping to protect the transcript."
)
return
durable_turn_lease_thread = threading.Thread(
target=_refresh_durable_turn_lease,
name="session-turn-lease-refresh",
daemon=True,
)
if _liveness_timeout is not None:
durable_turn_liveness_thread = (
turn_liveness.TurnLivenessWatchdog(
self,
session_id=getattr(self, "session_id", None) or session_id,
timeout_s=_liveness_timeout,
poll_s=_liveness_poll,
stop_event=durable_turn_lease_stop,
activity_lock=self._liveness_activity_lock(),
is_turn_active=_turn_is_active,
commit_abort=_commit_turn_liveness_abort,
deactivate_turn=_deactivate_turn_after_liveness_abort,
).make_thread()
)
relay_lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation(
profile_key=relay_runtime.current_profile_key(),
session_id=task_context["session_id"],
platform=task_context["platform"],
parent_session_id=relay_parent_session_id,
model=str(getattr(self, "model", None) or ""),
)
relay_turn = relay_runtime.SESSION_COORDINATOR.begin_turn(
relay_lease,
turn_id=relay_turn_id,
task_id=effective_task_id,
)
# Keep existing tests and external relay-runtime shims that return
# a minimal turn object compatible with the new opt-out flag.
if getattr(relay_turn, "relay_enabled", True):
start_task_run(
**task_context,
parent_session_id=getattr(self, "_parent_session_id", None) or "",
)
task_started = True
# Publish the conversation id for ambient Nous Portal tagging: every LLM call in this turn (loop,
# compression, vision, MoA, review forks) inherits `conversation=<root>`.
token = set_conversation_context(self._conversation_root_id())
# Routing/affinity scope the HOST declared; providers fall back to the id above when unset
# (#96811).
affinity_token = set_affinity_scope(
declared_conversation_scope_safe(self)
)
# Publish session accounting handles so auxiliary calls record usage into session_model_usage
# (#23270).
acct_token = set_accounting_context(
getattr(self, "_session_db", None),
getattr(self, "session_id", None),
)
from agent.auxiliary_client import scoped_runtime_main
# Keep the ContextVar scope local (tokens on the agent may be observed from another thread).
with bind_subagent_parent(self), scoped_runtime_main({}):
try:
if durable_turn_lease_thread is not None:
with durable_turn_lease_activity_lock:
durable_turn_lease_turn_active = True
# Stamp the activity clock at turn entry (#95663): `_last_activity_ts` persists across
# turns, so without this the watchdog would measure idle from the PREVIOUS turn and
# abort a fresh one.
self._touch_activity("starting new turn")
durable_turn_lease_thread.start()
if durable_turn_liveness_thread is not None:
durable_turn_liveness_thread.start()
result = run_conversation(
self,
user_message,
system_message,
conversation_history,
effective_task_id,
stream_callback,
persist_user_message,
persist_user_timestamp=persist_user_timestamp,
persist_user_display_kind=persist_user_display_kind,
persist_user_display_metadata=persist_user_display_metadata,
persist_user_platform_id=persist_user_platform_id,
moa_config=moa_config,
)
finally:
# Post-loop relay/task finalization must not receive a late refresh interrupt.
_stop_durable_turn_lease_refresher()
# Interrupt clear is deferred until after thread join (outer finally) so a refresher
# firing between stop and join cannot leave an interrupt behind.
terminal = result if isinstance(result, dict) else {}
if terminal.get("interrupted") is True:
relay_outcome = "cancelled"
elif terminal.get("failed") is True:
relay_outcome = "failed"
else:
relay_outcome = "success"
relay_runtime.SESSION_COORDINATOR.finish_logical_calls(
relay_turn,
outcome=relay_outcome,
)
if task_started:
task_finished = True
finish_task_run(**task_context, result=result)
return result
except BaseException as exc:
if isinstance(exc, (KeyboardInterrupt, InterruptedError)) or (
type(exc).__name__ == "CancelledError"
):
relay_outcome = "cancelled"
elif isinstance(exc, TimeoutError):
relay_outcome = "timed_out"
if relay_turn is not None:
relay_runtime.SESSION_COORDINATOR.finish_logical_calls(
relay_turn,
outcome=relay_outcome,
)
if task_started and not task_finished:
task_finished = True
finish_task_run(**task_context, error=exc)
raise
finally:
try:
if relay_turn is not None:
relay_runtime.SESSION_COORDINATOR.end_turn(
relay_turn,
outcome=relay_outcome,
)
finally:
try:
if relay_lease is not None:
relay_runtime.SESSION_COORDINATOR.release_conversation(
relay_lease
)
finally:
_stop_durable_turn_lease_refresher()
for _durable_thread in (
durable_turn_lease_thread,
durable_turn_liveness_thread,
):
if (
_durable_thread is not None
and _durable_thread.is_alive()
):
_durable_thread.join(timeout=1.0)
# Clear any refresher interrupt fired between stop and join; must run AFTER join.
_clear_durable_turn_lease_interrupt()
if durable_turn_lease is not None:
try:
_turn_db.release_session_turn_lease(
session_id, durable_turn_lease
)
except Exception:
logger.error(
"Failed to release session turn lease: %s",
session_id,
exc_info=True,
)
if (
getattr(self, "_active_session_turn_lease_holder", None)
== durable_turn_lease
):
self._active_session_turn_lease_holder = None
self._active_session_turn_lease_ttl_seconds = None
# Always clear mid-turn labels when the turn exits — including
# interrupted early returns that skip finalize_turn. Keep ts.
try:
self._reset_activity_labels_after_turn()
except Exception:
pass
if getattr(self, "_relay_pending_turn_id", None) == relay_turn_id:
self._relay_pending_turn_id = None
if acct_token is not None:
reset_accounting_context(acct_token)
if token is not None:
reset_conversation_context(token)
if affinity_token is not None:
reset_affinity_scope(affinity_token)
# Balance note_turn_started on every exit so the idle queue's live-turn count cannot leak.
try:
_review_queue.note_turn_finished()
except Exception:
pass
def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:
"""Simple chat interface that returns just the final response string.
``stream_callback`` is invoked with each text delta during streaming.
"""
result = self.run_conversation(message, stream_callback=stream_callback)
return result["final_response"]
_run_codex_app_server_turn = _forward("agent.codex_runtime", "run_codex_app_server_turn")
def main(
query: str = None,

View File

@@ -1779,6 +1779,7 @@ class TestExecuteToolCalls:
patch("run_agent.handle_function_call", side_effect=KeyboardInterrupt),
patch("run_agent._set_interrupt"),
patch("agent.interrupt_control._set_interrupt"),
patch("agent.turn_facade._set_interrupt"),
pytest.raises(KeyboardInterrupt),
):
agent._execute_tool_calls_sequential(mock_msg, messages, "task-1")
@@ -3425,6 +3426,7 @@ class TestRunConversation:
patch.object(agent, "_cleanup_task_resources"),
patch("run_agent._set_interrupt"),
patch("agent.interrupt_control._set_interrupt"),
patch("agent.turn_facade._set_interrupt"),
patch.object(
agent, "_interruptible_api_call", side_effect=interrupt_side_effect
),