mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-11 02:00:24 +00:00
19d00e3586
Move the 1-5s commit_session_memory() extraction off the POST /api/session/new request thread (was blocking + New Chat) into a fire-and-forget daemon thread, guarded by the existing per-session in-flight serialization. Data-loss prevention on managed stop: - server.py installs a SIGTERM handler that requests an orderly stop so serve_forever()'s finally block runs drain_all_on_shutdown() (the default SIGTERM disposition terminated without unwinding, losing in-flight commits). - session_lifecycle: bounded self-unregistering background-commit registry; a _draining flag so no NEW worker starts once the drain begins (the inline generation-drain commits any late arrival instead); a 30s overall drain deadline so a stuck worker cannot hang the stop path forever. Co-authored-by: luperrypf <luperrypf@users.noreply.github.com>
348 lines
14 KiB
Python
348 lines
14 KiB
Python
"""
|
|
Hermes WebUI memory-provider session lifecycle.
|
|
|
|
Batch-extraction memory providers (OpenViking, Holographic) only extract memories
|
|
when AIAgent.commit_memory_session() invokes provider on_session_end(). WebUI
|
|
sessions can be reopened and continued many times, so the lifecycle must guarantee:
|
|
|
|
1. Only completed, non-ephemeral turns are committable.
|
|
2. A commit finishing late must not erase work completed while it was in flight.
|
|
3. A failed commit preserves the uncommitted generation and owning agent handle.
|
|
4. Replacement/reopened agents cannot steal older dirty generations.
|
|
5. Overlapping commits are serialised via a per-session in-flight guard.
|
|
|
|
CLI-parity semantics — post-turn marking, boundary extraction/commit:
|
|
|
|
- Completed turn: Hermes core still mirrors the exchange through
|
|
run_agent.py::_sync_external_memory_for_turn(), MemoryManager sync_all(), and
|
|
provider sync_turn() WITHOUT triggering extraction. WebUI then calls
|
|
mark_turn_completed() after the saved/completed-turn boundary so later drains
|
|
know the synced session has uncommitted work and which agent owns it.
|
|
|
|
- Session boundary: commit_session_memory() triggers
|
|
AIAgent.commit_memory_session(), which calls provider on_session_end(),
|
|
posting /api/v1/sessions/<sid>/commit and triggering extraction. This is
|
|
called only at boundaries — /api/session/new with prev_session_id, explicit
|
|
agent eviction, LRU cache eviction, and shutdown drain — matching the CLI's
|
|
AIAgent.commit_memory_session()/shutdown_memory_provider() boundary.
|
|
|
|
The design uses a monotonic generation counter per session plus per-generation
|
|
agent ownership segments. mark_turn_completed() records which agent owns the new
|
|
generation. commit_session_memory() commits the earliest uncommitted segment and
|
|
compare-and-clears only that captured segment after success.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_lock = threading.Lock()
|
|
_condition = threading.Condition(_lock)
|
|
|
|
_sessions: dict[str, dict] = {}
|
|
|
|
# Background commit threads spawned by fire-and-forget paths (e.g. POST /api/session/new).
|
|
# Tracked so drain_all_on_shutdown() can join them before interpreter teardown,
|
|
# preventing silent data loss when daemon threads are abandoned mid-commit.
|
|
_background_commit_threads: set[threading.Thread] = set()
|
|
_background_commit_threads_lock = threading.Lock()
|
|
# Set once drain_all_on_shutdown() begins so no NEW background worker is started
|
|
# after the drain has snapshotted the registry. A late arrival is not lost: its
|
|
# uncommitted generation is still committed inline by the generation-drain loop
|
|
# below (which re-snapshots _sessions each pass and commits with wait=True).
|
|
_draining = False
|
|
|
|
|
|
def _register_background_commit_thread(t: threading.Thread) -> bool:
|
|
"""Register a fire-and-forget commit thread. Returns True if the caller
|
|
should start it. Returns False when shutdown draining has already begun —
|
|
the caller must NOT start a new worker in that window; the inline
|
|
generation-drain will commit the pending work instead."""
|
|
with _background_commit_threads_lock:
|
|
if _draining:
|
|
return False
|
|
_background_commit_threads.add(t)
|
|
return True
|
|
|
|
|
|
def _unregister_background_commit_thread(t: threading.Thread) -> None:
|
|
# A completed worker removes itself so the registry does not grow without
|
|
# bound over the process lifetime; drain only needs threads still running.
|
|
with _background_commit_threads_lock:
|
|
_background_commit_threads.discard(t)
|
|
|
|
|
|
def _drain_background_commit_threads(timeout: float = 5.0, deadline_fn=None) -> None:
|
|
"""Join tracked fire-and-forget commit threads. When ``deadline_fn`` is given
|
|
(returns the remaining overall shutdown budget in seconds), each join is
|
|
clamped to that budget and the phase stops early once it is exhausted, so the
|
|
join phase cannot overrun ``drain_all_on_shutdown``'s deadline."""
|
|
with _background_commit_threads_lock:
|
|
threads = list(_background_commit_threads)
|
|
_background_commit_threads.clear()
|
|
for t in threads:
|
|
if not t.is_alive():
|
|
continue
|
|
if deadline_fn is None:
|
|
t.join(timeout)
|
|
continue
|
|
remaining = deadline_fn()
|
|
if remaining <= 0:
|
|
return
|
|
t.join(min(timeout, remaining))
|
|
|
|
|
|
def _new_entry() -> dict:
|
|
return {
|
|
"generation": 0,
|
|
"committed_generation": 0,
|
|
"agent": None,
|
|
"in_flight": False,
|
|
"segments": [],
|
|
}
|
|
|
|
|
|
def _reset_for_tests() -> None:
|
|
with _condition:
|
|
_sessions.clear()
|
|
_condition.notify_all()
|
|
|
|
|
|
def register_agent(session_id: str, agent) -> None:
|
|
"""Register the current agent handle for future completed generations.
|
|
|
|
Existing dirty generations keep their original segment owner. This prevents
|
|
a rebuilt/reopened agent from overwriting the handle needed to retry older
|
|
failed memory-provider work.
|
|
"""
|
|
if not session_id:
|
|
return
|
|
with _condition:
|
|
entry = _sessions.setdefault(session_id, _new_entry())
|
|
entry["agent"] = agent
|
|
_condition.notify_all()
|
|
|
|
|
|
def unregister_agent(session_id: str) -> None:
|
|
"""Clear the current future-generation agent handle.
|
|
|
|
Dirty segment owners are intentionally preserved so failed work remains
|
|
retryable even if the cache drops the current agent reference.
|
|
"""
|
|
if not session_id:
|
|
return
|
|
with _condition:
|
|
entry = _sessions.get(session_id)
|
|
if entry is not None:
|
|
entry["agent"] = None
|
|
_condition.notify_all()
|
|
|
|
|
|
def discard_session(session_id: str) -> bool:
|
|
"""Permanently drop a session's lifecycle entry to bound memory growth.
|
|
|
|
The ``_sessions`` dict is process-global and historically only ever grew:
|
|
``register_agent`` / ``mark_turn_completed`` insert keys but no runtime path
|
|
ever removed them, so every unique ``session_id`` the WebUI touched leaked a
|
|
permanent entry (issue #3506). Over days of use on a large install this is a
|
|
monotonic, unbounded climb.
|
|
|
|
This removes the entry, but only when it is provably safe to do so: no commit
|
|
is in flight and there is no uncommitted memory work that still needs the
|
|
retained agent handle. If the entry is busy or dirty it is left untouched so
|
|
failed batch-extraction memory work stays retryable -- exactly the invariant
|
|
``unregister_agent`` and ``_evict_session_agent`` already preserve.
|
|
|
|
Returns True when the entry was removed (or was already absent), False when
|
|
it was retained because work is still pending.
|
|
"""
|
|
if not session_id:
|
|
return False
|
|
with _condition:
|
|
entry = _sessions.get(session_id)
|
|
if entry is None:
|
|
return True
|
|
if entry["in_flight"]:
|
|
return False
|
|
if entry["generation"] > entry["committed_generation"]:
|
|
return False
|
|
del _sessions[session_id]
|
|
_condition.notify_all()
|
|
return True
|
|
|
|
|
|
def mark_turn_completed(session_id: str, *, agent=None) -> int:
|
|
if not session_id:
|
|
return 0
|
|
with _condition:
|
|
entry = _sessions.setdefault(session_id, _new_entry())
|
|
if agent is not None:
|
|
entry["agent"] = agent
|
|
owner = agent if agent is not None else entry.get("agent")
|
|
entry["generation"] += 1
|
|
generation = entry["generation"]
|
|
segments = entry["segments"]
|
|
if segments and not entry["in_flight"] and segments[-1].get("agent") is owner:
|
|
segments[-1]["end"] = generation
|
|
else:
|
|
segments.append({"start": generation, "end": generation, "agent": owner})
|
|
_condition.notify_all()
|
|
return generation
|
|
|
|
|
|
def has_uncommitted_work(session_id: str) -> bool:
|
|
if not session_id:
|
|
return False
|
|
with _lock:
|
|
entry = _sessions.get(session_id)
|
|
if entry is None:
|
|
return False
|
|
return entry["generation"] > entry["committed_generation"]
|
|
|
|
|
|
def _first_uncommitted_segment(entry: dict) -> dict | None:
|
|
committed = entry["committed_generation"]
|
|
for segment in entry["segments"]:
|
|
if segment["end"] > committed:
|
|
return segment
|
|
return None
|
|
|
|
|
|
def commit_session_memory(session_id: str, agent=None, *, wait: bool = False, timeout: float | None = None) -> bool:
|
|
if not session_id:
|
|
return False
|
|
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
with _condition:
|
|
entry = _sessions.get(session_id)
|
|
if entry is None:
|
|
return False
|
|
while entry["in_flight"]:
|
|
if not wait:
|
|
return False
|
|
if deadline is None:
|
|
_condition.wait()
|
|
else:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return False
|
|
_condition.wait(remaining)
|
|
entry = _sessions.get(session_id)
|
|
if entry is None:
|
|
return False
|
|
if entry["generation"] <= entry["committed_generation"]:
|
|
return False
|
|
segment = _first_uncommitted_segment(entry)
|
|
if segment is None:
|
|
return False
|
|
effective_agent = segment.get("agent")
|
|
if effective_agent is None:
|
|
effective_agent = agent if agent is not None else entry.get("agent")
|
|
if effective_agent is not None:
|
|
segment["agent"] = effective_agent
|
|
if effective_agent is None:
|
|
return False
|
|
captured_generation = segment["end"]
|
|
entry["in_flight"] = True
|
|
|
|
try:
|
|
effective_agent.commit_memory_session()
|
|
except Exception:
|
|
logger.exception("commit_memory_session() failed for session %s", session_id)
|
|
with _condition:
|
|
re_entry = _sessions.get(session_id)
|
|
if re_entry is not None:
|
|
re_entry["in_flight"] = False
|
|
_condition.notify_all()
|
|
return False
|
|
|
|
with _condition:
|
|
re_entry = _sessions.get(session_id)
|
|
if re_entry is not None:
|
|
re_entry["in_flight"] = False
|
|
if captured_generation > re_entry["committed_generation"]:
|
|
re_entry["committed_generation"] = captured_generation
|
|
committed = re_entry["committed_generation"]
|
|
segments = re_entry["segments"]
|
|
while segments and segments[0]["end"] <= committed:
|
|
segments.pop(0)
|
|
if segments and segments[0]["start"] <= committed:
|
|
segments[0]["start"] = committed + 1
|
|
_condition.notify_all()
|
|
return True
|
|
|
|
|
|
def drain_all_on_shutdown(deadline: float = 30.0) -> None:
|
|
# Signal that draining has begun so no NEW background commit worker is
|
|
# started after this point (see _register_background_commit_thread). Any
|
|
# already-registered worker is joined below; a caller that loses the race
|
|
# (registered after our snapshot, or refused a start) still has its pending
|
|
# generation committed by the inline loop.
|
|
global _draining
|
|
with _background_commit_threads_lock:
|
|
_draining = True
|
|
|
|
# Overall wall-clock budget so a stuck worker holding in_flight cannot hang
|
|
# the shutdown path forever. Start the clock BEFORE the join phase so the
|
|
# join and the inline drain share one budget. The inline commit runs in a
|
|
# daemon worker joined only for the remaining budget — otherwise a provider
|
|
# wedged inside commit_memory_session() (whether we are waiting on an
|
|
# existing in_flight commit, OR the inline drain owns the segment and calls
|
|
# the provider directly) would block indefinitely and the deadline check
|
|
# would never run again.
|
|
started = time.monotonic()
|
|
|
|
def _remaining() -> float:
|
|
return deadline - (time.monotonic() - started)
|
|
|
|
def _commit_within_budget(session_id: str, budget: float) -> bool:
|
|
"""Run one commit in a daemon worker, join at most `budget` seconds.
|
|
Returns True only if the commit completed within budget AND made
|
|
progress. On timeout the daemon worker is left running (it will finish
|
|
or die at interpreter exit; either way it mutates generation state only
|
|
under _condition, so nothing is corrupted) and we report no progress so
|
|
the drain can honor its deadline instead of hanging on a wedged
|
|
provider."""
|
|
holder = {"ok": False}
|
|
|
|
def _run():
|
|
holder["ok"] = commit_session_memory(session_id, wait=True)
|
|
|
|
worker = threading.Thread(target=_run, daemon=True, name=f"drain-commit-{session_id}")
|
|
worker.start()
|
|
worker.join(budget)
|
|
return holder["ok"] if not worker.is_alive() else False
|
|
|
|
# Drain in-flight background commit threads first (fire-and-forget from
|
|
# POST /api/session/new) so their commit work completes before we drain any
|
|
# remaining uncommitted generations inline. Each join consumes the shared
|
|
# budget so it cannot overrun the overall deadline.
|
|
_drain_background_commit_threads(deadline_fn=_remaining)
|
|
|
|
while True:
|
|
with _lock:
|
|
snapshot = [sid for sid, entry in _sessions.items() if entry["generation"] > entry["committed_generation"]]
|
|
if not snapshot:
|
|
return
|
|
remaining = _remaining()
|
|
if remaining <= 0:
|
|
logger.warning("drain_all_on_shutdown: deadline hit with uncommitted sessions: %s", sorted(snapshot))
|
|
return
|
|
|
|
made_progress = False
|
|
for sid in snapshot:
|
|
budget = _remaining()
|
|
if budget <= 0:
|
|
logger.warning("drain_all_on_shutdown: deadline hit with uncommitted sessions: %s", sorted(snapshot))
|
|
return
|
|
# Budget-bounded (covers both the wait-for-in_flight path AND the
|
|
# unbounded provider call when the inline drain owns the segment).
|
|
if _commit_within_budget(sid, budget):
|
|
made_progress = True
|
|
if not made_progress:
|
|
logger.debug("drain_all_on_shutdown: stopped with uncommitted sessions: %s", sorted(snapshot))
|
|
return
|