Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,15 @@ def init_agent(

# SQLite session store (optional -- provided by CLI or gateway)
agent._session_db = session_db
# Whether close() must also close that handle. Default False: a
# caller-supplied session_db is almost always the SHARED launch handle,
# which outlives every agent and must never be closed here. Callers that
# hand over a DEDICATED handle (the gateway's per-profile state.db opens)
# set this True at the point ownership transfers, so teardown releases the
# sqlite fds and the token-writer thread instead of leaking them for the
# life of the process. Also set True on the lazy self-open in
# _get_session_db_for_recall, where nothing else holds a reference.
agent._owns_session_db = False
agent._parent_session_id = parent_session_id
# A close flush and the worker's turn-start flush can overlap. The durable
# marker is attached to each in-memory message dict, so its test-and-append
Expand Down
23 changes: 22 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,9 @@ def _get_session_db_for_recall(self):
from hermes_state import SessionDB

self._session_db = SessionDB()
# We opened it here, so nothing else holds a reference — this agent
# is its only owner and close() must release it.
self._owns_session_db = True
return self._session_db
except Exception:
logger.debug("SessionDB unavailable for recall", exc_info=True)
Expand Down Expand Up @@ -4324,15 +4327,33 @@ def close(self) -> None:
# must leave it open). end_session() is first-reason-wins and no-ops on
# an already-ended row, so this never clobbers a 'compression' /
# 'cron_complete' / 'cli_close' reason set by an earlier terminal path.
session_db = getattr(self, "_session_db", None)
try:
if getattr(self, "_end_session_on_close", True):
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "session_id", None)
if session_db and session_id:
session_db.end_session(session_id, "agent_close")
except Exception:
pass

# 9. Close the SQLite handle itself, but ONLY when this agent owns it.
# end_session() above finalizes the session ROW; it does not release the
# connection. For the shared launch handle that is correct — it outlives
# every agent — so _owns_session_db defaults False and this is a no-op.
# A DEDICATED handle (the gateway's per-profile state.db opens, and the
# lazy self-open in _get_session_db_for_recall) has no other owner: left
# unclosed it keeps its db/-wal/-shm fds and its background token-writer
# thread, and once that writer has started the instance pins ITSELF via
# atexit.register(_drain_token_queue_at_exit) — which only close()
# unregisters — so it survives for the life of the process.
# Cleared first so the documented idempotency of close() holds.
try:
if getattr(self, "_owns_session_db", False) and session_db is not None:
self._owns_session_db = False
session_db.close()
except Exception:
pass

def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:
"""
Recover todo state from conversation history.
Expand Down
Loading
Loading