fix: lazy session creation — defer DB row until first message (#18370)

Prevents ghost sessions from accumulating in state.db when the TUI/web
dashboard is opened and closed without sending a message.

Changes:
- run_agent.py: Add _ensure_db_session() gate method, called at
  run_conversation() entry. Remove eager create_session() from __init__.
  Handle compression rotation flag correctly.
- tui_gateway/server.py: Remove eager db.create_session() in
  _start_agent_build(). Add post-first-message pending_title re-apply.
- hermes_state.py: Extract _insert_session_row() shared helper (DRY).
  Add prune_empty_ghost_sessions() for one-time migration.
- cli.py: One-time ghost session prune on startup. Fix _pending_title
  to call _ensure_db_session() before set_session_title().
- hermes_cli/main.py: Guard TUI exit summary on message_count > 0.
- tests: Update test_860_dedup to call _ensure_db_session() before
  direct _flush_messages_to_session_db() calls.

Closes: ghost session clutter in hermes sessions list and web dashboard.
This commit is contained in:
Siddharth Balyan
2026-05-01 18:39:12 +05:30
committed by GitHub
parent 20132435c0
commit c5b4c48165
7 changed files with 322 additions and 93 deletions

View File

@@ -280,7 +280,7 @@ def _notify_session_boundary(event_type: str, session_id: str | None) -> None:
pass
def _finalize_session(session: dict | None) -> None:
def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None:
"""Best-effort finalize hook + memory commit for a session."""
if not session or session.get("_finalized"):
return
@@ -299,13 +299,24 @@ def _finalize_session(session: dict | None) -> None:
except Exception:
pass
session_id = getattr(agent, "session_id", None) or session.get("session_key")
session_key = session.get("session_key")
session_id = getattr(agent, "session_id", None) or session_key
_notify_session_boundary("on_session_finalize", session_id)
# Mark session ended in DB so it doesn't linger as a ghost row in /resume.
# Adapted from #18283 (luyao618) and #18299 (Bartok9).
if session_key:
try:
db = _get_db()
if db is not None:
db.end_session(session_key, end_reason)
except Exception:
pass
def _shutdown_sessions() -> None:
for session in list(_sessions.values()):
_finalize_session(session)
_finalize_session(session, end_reason="tui_shutdown")
try:
worker = session.get("slash_worker")
if worker:
@@ -539,32 +550,8 @@ def _start_agent_build(sid: str, session: dict) -> None:
finally:
_clear_session_context(tokens)
db = _get_db()
if db is not None:
db.create_session(key, source="tui", model=_resolve_model())
pending_title = (current.get("pending_title") or "").strip()
if pending_title:
try:
title_applied = db.set_session_title(key, pending_title)
if title_applied:
current["pending_title"] = None
else:
existing_row = db.get_session(key)
existing_title = ((existing_row or {}).get("title") or "").strip()
if existing_title == pending_title:
current["pending_title"] = None
else:
logger.info(
"Pending title still queued for session %s (wanted=%r, current=%r)",
sid,
pending_title,
existing_title,
)
except ValueError as e:
current["pending_title"] = None
logger.info("Dropping pending title for session %s: %s", sid, e)
except Exception:
logger.warning("Failed to apply pending title for session %s", sid, exc_info=True)
# Session DB row deferred to first run_conversation() call.
# pending_title applied post-first-message (see cli.exec handler).
current["agent"] = agent
try:
@@ -2994,6 +2981,17 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
payload["rendered"] = rendered
_emit("message.complete", sid, payload)
# Apply pending_title now that the DB row exists.
_pending = session.get("pending_title")
if _pending and status == "complete":
_pdb = _get_db()
if _pdb:
try:
if _pdb.set_session_title(session.get("session_key") or sid, _pending):
session["pending_title"] = None
except Exception:
pass # Best effort — auto-title will handle it below
if (
status == "complete"
and isinstance(raw, str)