Skip to content
Closed
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
2 changes: 2 additions & 0 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3172,6 +3172,8 @@ def _atomic(conn):
try:
db._resolve_title_conflict(conn, session_id, clean_title)
except ValueError as exc: # the DB's uniqueness rule; undo the INSERT
# No ``on_session_delete`` here: this rolls back the row inserted in the
# same transaction, so the session never existed — firing would double-count it.
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
return None, f"title:{exc}"
conn.execute("UPDATE sessions SET title = ? WHERE id = ?", (clean_title, session_id))
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ def _mtime_drift(shell_hooks, spec, entry) -> tuple[bool | None, str, str]:
},
"on_session_finalize": {"session_id": "test-session"},
"on_session_reset": {"session_id": "test-session"},
"on_session_delete": {"session_id": "test-session"},
"pre_api_request": {
"session_id": "test-session", "task_id": "test-task", "platform": "cli",
"model": "claude-sonnet-4-6", "provider": "anthropic",
Expand Down
19 changes: 19 additions & 0 deletions hermes_cli/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,22 @@ def finalize_session(**kwargs: Any) -> list[Any]:
logger.warning("Core Relay session finalization failed", exc_info=True)

return _plugin_hooks("on_session_finalize", **kwargs)


def notify_session_deleted(session_id: str, **kwargs: Any) -> List[Any]:
"""Fire the ``on_session_delete`` post-commit observer (#124511).

Call only after the delete transaction commits and only when a row was
actually removed. Observer-only: returns ignored, failures fail open
(logged, never raised) so a slow/broken plugin cannot break deletion.
Unbounded/caller-thread like ``on_session_finalize`` — cleanup work
("close a handle, unlink a directory") must not be abandoned mid-way.
# ponytail: single process-local fan-out; per-surface reason/platform
# enrichment stays with callers, add a queue here only if a callback
# measurably blocks a delete path.
"""
try:
return invoke_hook("on_session_delete", session_id=session_id, **kwargs)
except Exception:
logger.warning("on_session_delete dispatch failed", exc_info=True)
return []
2 changes: 1 addition & 1 deletion hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def _install_plugin_debug_handler(force: bool = False) -> None:
# Run-all-then-pick-first (see get_plugin_error_classification). Privacy: error_message/
# error_body may be unredacted.
"transform_api_error_classification", "on_session_start", "on_session_end",
"on_session_finalize", "on_session_reset",
"on_session_finalize", "on_session_reset", "on_session_delete",
# on_skill_lifecycle: successful skill lifecycle facts (local skill name visible to plugins).
"on_skill_lifecycle", "subagent_start", "subagent_stop",
# pre_gateway_dispatch: once per incoming MessageEvent, after the internal-event guard, BEFORE
Expand Down
30 changes: 30 additions & 0 deletions hermes_state_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,36 @@ def _id_chunks(ids, size: int = _SQL_IN_CHUNK):
yield ids[start:start + size]


def emit_session_deleted(deleted_ids=None, *, reason=None, sessions_dir=None):
"""Fire the ``on_session_delete`` post-commit observer for committed deletes (#124511).

Late-bound seam: the state layer must not import ``hermes_cli`` at module
top level, so the dispatch resolves inside the call and fails open — a
missing/broken plugin stack never breaks deletion. Call only after the
write transaction commits, with the ids actually removed. One invocation
per delete operation; ``deleted_ids`` rides along only when the count
differs from 1.
"""
ids = sorted({sid for sid in (deleted_ids or ()) if isinstance(sid, str) and sid})
if not ids:
return
try:
from hermes_cli.lifecycle import notify_session_deleted
except Exception:
return
payload: dict = {"session_id": ids[0]}
if len(ids) > 1:
payload["deleted_ids"] = ids
if reason is not None:
payload["reason"] = reason
if sessions_dir is not None:
payload["sessions_dir"] = str(sessions_dir)
try:
notify_session_deleted(**payload)
except Exception:
logger.warning("on_session_delete dispatch failed", exc_info=True)


_FTS_TRIGGERS = ("messages_fts_insert", "messages_fts_delete", "messages_fts_update",
"messages_fts_trigram_insert", "messages_fts_trigram_delete", "messages_fts_trigram_update")

Expand Down
6 changes: 5 additions & 1 deletion hermes_state_maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from hermes_state_common import (
AUTO_VACUUM_MIN_FREELIST_RATIO, _id_chunks, _non_continuation_child_sql, _placeholders, _sql_session_last_active,
escape_like as _escape_like
escape_like as _escape_like, emit_session_deleted,
)
from hermes_startup_watchdog import report_startup_progress

Expand Down Expand Up @@ -132,6 +132,8 @@ def _do(conn):
removed_ids = self._execute_write(_do) or []
for sid in removed_ids if sessions_dir else ():
self._remove_session_files(sessions_dir, sid)
if removed_ids:
emit_session_deleted(removed_ids, reason="ghost_prune", sessions_dir=sessions_dir)
return len(removed_ids)

def _guarded_ids(self, conn, ids: Iterable[str]) -> set:
Expand Down Expand Up @@ -350,6 +352,8 @@ def _do(conn):
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
if count:
emit_session_deleted(removed_ids, reason="prune", sessions_dir=sessions_dir)
return count

def _page_pragmas(self, names: tuple[str, ...], fail_msg: str) -> Optional[list]:
Expand Down
11 changes: 9 additions & 2 deletions hermes_state_profile_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@
import logging
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple

from hermes_state_common import _id_chunks, _placeholders as _session_ids_placeholders
from hermes_state_common import (
_id_chunks,
_placeholders as _session_ids_placeholders,
emit_session_deleted,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -253,7 +257,10 @@ def _do(conn) -> bool:
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
self._delete_unreferenced_system_prompts(conn)
return True
return bool(self._execute_write(_do))
deleted = self._execute_write(_do)
if deleted:
emit_session_deleted([session_id], reason="profile_repair")
return bool(deleted)

# ── routing index ─────────────────────────────────────────────────────────

Expand Down
8 changes: 8 additions & 0 deletions hermes_state_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
_shape_preview, _sql_preview_raw, QUEUED_PROMPT_METADATA_KEY,
_sql_in_window, _sql_json_extract, _sql_session_last_active, _sql_session_last_active_by_id,
escape_like as _escape_like, _SQL_IN_CHUNK, _id_chunks, _placeholders as _session_ids_placeholders,
emit_session_deleted,
)
from hermes_state_compression import _CHAIN_CAP

Expand Down Expand Up @@ -1747,6 +1748,8 @@ def _do(conn):
deleted = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
if deleted:
emit_session_deleted(removed_ids, sessions_dir=sessions_dir)
return bool(deleted)

def delete_session_if_empty(self, session_id: str, sessions_dir: Optional[Path] = None) -> bool:
Expand Down Expand Up @@ -1779,6 +1782,7 @@ def _do(conn):
deleted = self._execute_write(_do)
if deleted:
self._remove_session_files(sessions_dir, session_id)
emit_session_deleted([session_id], sessions_dir=sessions_dir)
return deleted

def delete_sessions(
Expand Down Expand Up @@ -1860,6 +1864,8 @@ def _do(conn):
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
if count:
emit_session_deleted(removed_ids, sessions_dir=sessions_dir)
return count

# Shared by count_empty_sessions / delete_empty_sessions so badge and sweep agree. message_count
Expand Down Expand Up @@ -1898,6 +1904,8 @@ def _do(conn):
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
if count:
emit_session_deleted(removed_ids, reason="empty_sweep", sessions_dir=sessions_dir)
return count

def archive_sessions(
Expand Down
121 changes: 121 additions & 0 deletions tests/hermes_state/test_session_delete_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""on_session_delete post-commit observer (#124511).

Delete paths must notify plugins after the commit so per-session on-disk
state can be cleaned up without polling the store. Failing observers fail
open — deletion itself never breaks.
"""

import pytest

from hermes_cli import lifecycle
from hermes_cli.plugins import VALID_HOOKS
from hermes_state import SessionDB


@pytest.fixture()
def db(tmp_path):
session_db = SessionDB(db_path=tmp_path / "test_state.db")
yield session_db
session_db.close()


@pytest.fixture(autouse=True)
def _no_fts_rebuild_throttle(monkeypatch):
monkeypatch.setattr(SessionDB, "_FTS_REBUILD_MIN_PAUSE", 0.0)
monkeypatch.setattr(SessionDB, "_FTS_REBUILD_DUTY_FACTOR", 0.0)


@pytest.fixture()
def fired(monkeypatch):
calls = []

def _spy(**kwargs):
calls.append(kwargs)
return []

monkeypatch.setattr(lifecycle, "notify_session_deleted", _spy)
return calls


def test_hook_is_registered():
assert "on_session_delete" in VALID_HOOKS


def test_delete_session_fires_once(db, fired):
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="hi")

assert db.delete_session("s1") is True

assert len(fired) == 1
assert fired[0]["session_id"] == "s1"
assert "deleted_ids" not in fired[0]


def test_delete_session_unknown_id_does_not_fire(db, fired):
assert db.delete_session("missing") is False
assert fired == []


def test_delete_sessions_bulk_fires_once_with_ids(db, fired):
db.create_session(session_id="s1", source="cli")
db.create_session(session_id="s2", source="cli")
db.create_session(session_id="keep", source="cli")

assert db.delete_sessions(["s1", "s2", "missing"]) == 2

assert len(fired) == 1
assert fired[0]["session_id"] == "s1"
assert fired[0]["deleted_ids"] == ["s1", "s2"]


def test_delete_empty_sessions_carries_reason(db, fired):
db.create_session(session_id="empty", source="cli")
db.end_session("empty", "done")

assert db.delete_empty_sessions() == 1

assert len(fired) == 1
assert fired[0]["session_id"] == "empty"
assert fired[0]["reason"] == "empty_sweep"


def test_delete_moved_session_fires_with_reason(db, fired):
db.create_session(session_id="moved1", source="cli")

assert db.delete_moved_session("moved1") is True

assert len(fired) == 1
assert fired[0]["session_id"] == "moved1"
assert fired[0]["reason"] == "profile_repair"


def test_delete_moved_session_unknown_id_does_not_fire(db, fired):
assert db.delete_moved_session("missing") is False
assert fired == []


def test_failing_observer_does_not_break_delete(db, monkeypatch):
def _boom(**kwargs):
raise RuntimeError("plugin down")

monkeypatch.setattr(lifecycle, "notify_session_deleted", _boom)
db.create_session(session_id="s1", source="cli")

assert db.delete_session("s1") is True
assert db.get_session("s1") is None


def test_notify_dispatches_hook_and_fails_open(monkeypatch):
seen = []
monkeypatch.setattr(
lifecycle, "invoke_hook", lambda name, **kw: seen.append((name, kw)) or []
)
lifecycle.notify_session_deleted("s1", reason="prune")
assert seen == [("on_session_delete", {"session_id": "s1", "reason": "prune"})]

def _boom(name, **kw):
raise RuntimeError("dispatch down")

monkeypatch.setattr(lifecycle, "invoke_hook", _boom)
assert lifecycle.notify_session_deleted("s1") == []
1 change: 1 addition & 0 deletions website/docs/developer-guide/plugins/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,7 @@ Each hook is documented in full on the **[Event Hooks reference](../../user-guid
| [`on_session_end`](../../user-guide/features/hooks.md#on_session_end) | End of every `run_conversation` call + CLI exit | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | ignored |
| [`on_session_finalize`](../../user-guide/features/hooks.md#on_session_finalize) | CLI/gateway tears down an active session | `session_id: str \| None, platform: str` | ignored |
| [`on_session_reset`](../../user-guide/features/hooks.md#on_session_reset) | Gateway swaps in a new session key (`/new`, `/reset`) | `session_id: str, platform: str` | ignored |
| [`on_session_delete`](../../user-guide/features/hooks.md#on_session_delete) | A session row delete commits (single, bulk, sweep, prune) | `session_id: str`, optional `deleted_ids: list[str]`, `reason: str`, `sessions_dir: str` | ignored |
| [`gateway_platform_event`](../../user-guide/features/hooks.md#gateway_platform_event) | An authorized platform-native event is normalized at the gateway boundary (Telegram reactions currently) | `platform: str, event_type: str, payload: dict` | ignored |
| `kanban_task_claimed` | A kanban task is claimed (dispatcher process, before the worker spawns) | `task_id: str, board: str \| None, assignee: str \| None, run_id: int \| None, profile_name: str` | ignored |
| `kanban_task_completed` | A kanban task completes (worker process) | `task_id, board, assignee, run_id, profile_name, summary: str \| None` | ignored |
Expand Down
28 changes: 28 additions & 0 deletions website/docs/user-guide/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ Payload fields below are the exact event-specific fields supplied by each call s
| `on_session_end` | Observer | Canonically at each turn finalization; CLI/TUI exits have additional reduced legacy shapes. Return ignored. | Canonical: `session_id`, `task_id`, `turn_id`, `completed`, `failed`, `interrupted`, `turn_exit_reason`, `model`, `platform`; exit paths may add `reason`/`api_request_id` and omit fields. | IDs, model/platform, and outcome; canonical payload has no message body. |
| `on_session_finalize` | Observer | CLI/TUI/gateway teardown through `finalize_session`; gateway shutdown may finalize without a reset. Return ignored. | Surface-dependent `session_id`, `platform`, optionally `reason`, `old_session_id`, `new_session_id` | Session and routing identifiers. |
| `on_session_reset` | Observer | CLI/TUI session boundary and gateway after the replacement session exists; return ignored. | CLI: `session_id`, `platform`, `reason`; TUI: `session_id`, `platform`; gateway: those plus `reason`, `old_session_id`, `new_session_id` | Session and routing identifiers. |
| `on_session_delete` | Observer | After a session row delete commits (single, bulk, empty-sweep, prune, ghost prune); return ignored. | `session_id`, optional `deleted_ids` (full set when the count differs from 1), `reason`, `sessions_dir` | Session identifiers only; no message content. |
| `agent_loop_stopped` | Observer | Immediately after a real running agent is interrupted — gateway `_interrupt_and_clear_session` or TUI/desktop `session.interrupt`; return ignored. | `session_key`, `platform`, `reason`, `invalidation_reason` | Session/routing identifiers and interruption reasons; no message body. |
| `on_skill_lifecycle` | Observer | After an authoritative skill-usage state change; return ignored. | `action`, `skill_name`, `provenance`, `task_id`, `session_id`, `use_count`, `reused`, `reuse_after_patch` | Exposes the local skill name and provenance. |
| `subagent_start` | Observer | Child constructed and about to run; return ignored. | `parent_session_id`, `parent_turn_id`, `parent_subagent_id`, `child_session_id`, `child_subagent_id`, `child_role`, `child_goal` | Child goal may contain user/project content. |
Expand Down Expand Up @@ -1081,6 +1082,33 @@ def my_callback(session_id: str, platform: str, **kwargs):

---

### `on_session_delete`

Fires **once per delete operation after the transaction commits**, when a session row was actually removed — covering single deletes, bulk deletes, the empty-session sweep, retention prunes, and ghost-prune maintenance. This lets plugins that keep per-session on-disk state clean up without polling the session store.

**Callback signature:**

```python
def my_callback(session_id: str, **kwargs):
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `session_id` | `str` | The session that was deleted. Always present. |
| `deleted_ids` | `list[str]`, optional | Full set removed by the operation, including cascade-deleted delegate children and every row in a bulk delete. Present when the count differs from 1. |
| `reason` | `str`, optional | Why the delete happened (`"prune"`, `"ghost_prune"`, `"empty_sweep"`, …). |
| `sessions_dir` | `str`, optional | Absolute path of the profile's `sessions/` dir, so a plugin does not have to re-derive the per-profile home. |

**Fires:** Post-commit only, and only when the delete actually happened (unknown ids in a bulk delete are skipped silently). Return ignored; a failing callback is logged and skipped like every other observer. The payload carries no message content — only ids, the reason, and the directory.

**Delivery scope:** Process-local — the callback runs only in the process that committed the delete. It is not a durable cross-process delivery channel, and it carries no exactly-once guarantee: external side effects driven from the callback should tolerate a missed or duplicated invocation.

**Return value:** Ignored.

**Use cases:** Delete a per-session sidecar database or cache directory, emit "session deleted" analytics, release per-session external resources.

---

See the **[Build a Plugin guide](../../developer-guide/plugins/index.md)** for the full walkthrough including tool schemas, handlers, and advanced hook patterns.

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,7 @@ def register(ctx):
| [`on_session_end`](../../user-guide/features/hooks.md#on_session_end) | 每次 `run_conversation` 调用结束 + CLI 退出 | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | 忽略 |
| [`on_session_finalize`](../../user-guide/features/hooks.md#on_session_finalize) | CLI/网关销毁活跃会话 | `session_id: str \| None, platform: str` | 忽略 |
| [`on_session_reset`](../../user-guide/features/hooks.md#on_session_reset) | 网关切换新会话键(`/new`、`/reset`) | `session_id: str, platform: str` | 忽略 |
| [`on_session_delete`](../../user-guide/features/hooks.md#on_session_delete) | 会话行删除提交后(单删、批量、sweep、prune) | `session_id: str`,可选 `deleted_ids: list[str]`、`reason: str`、`sessions_dir: str` | 忽略 |

大多数钩子是即发即忘的观察者——其返回值被忽略。例外是 `pre_llm_call`,它可以向对话中注入上下文。

Expand Down
Loading