diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index ab9f4d78b488..a2d02b490a30 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -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)) diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index b9fbe35ebc21..cdb6f66873db 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -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", diff --git a/hermes_cli/lifecycle.py b/hermes_cli/lifecycle.py index de01dd04b845..d757d0e1bd98 100644 --- a/hermes_cli/lifecycle.py +++ b/hermes_cli/lifecycle.py @@ -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 [] diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index b905d335de7f..3b2a81e72223 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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 diff --git a/hermes_state_common.py b/hermes_state_common.py index 33a35606368e..61e5c19f8249 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -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") diff --git a/hermes_state_maintenance.py b/hermes_state_maintenance.py index 8d512665a692..38cff9cbcd54 100644 --- a/hermes_state_maintenance.py +++ b/hermes_state_maintenance.py @@ -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 @@ -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: @@ -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]: diff --git a/hermes_state_profile_repair.py b/hermes_state_profile_repair.py index 313fc80abdbd..00c8b1d6735d 100644 --- a/hermes_state_profile_repair.py +++ b/hermes_state_profile_repair.py @@ -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__) @@ -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 ───────────────────────────────────────────────────────── diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 87afa702895a..f9585e626784 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -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 @@ -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: @@ -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( @@ -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 @@ -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( diff --git a/tests/hermes_state/test_session_delete_hook.py b/tests/hermes_state/test_session_delete_hook.py new file mode 100644 index 000000000000..639e9624eed6 --- /dev/null +++ b/tests/hermes_state/test_session_delete_hook.py @@ -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") == [] diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index aff9cf1cabc4..1d5d454dee96 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -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 | diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index ea15a468b954..235bf09bc099 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -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. | @@ -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. --- diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md index 486ec4e8c911..2182a9ca4b62 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md @@ -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`,它可以向对话中注入上下文。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/hooks.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/hooks.md index dc590b393814..515221f21d39 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/hooks.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/hooks.md @@ -393,6 +393,7 @@ def register(ctx): | `on_session_end` | 观察者 | Canonical 路径在每轮 finalize;CLI/TUI 退出还有精简 legacy shape。 | Canonical:`session_id`, `task_id`, `turn_id`, `completed`, `failed`, `interrupted`, `turn_exit_reason`, `model`, `platform`;退出路径可能增加 `reason`/`api_request_id` 并省略字段。 | ID、model/platform 和结果;canonical payload 无消息正文。 | | `on_session_finalize` | 观察者 | CLI/TUI/gateway 通过 `finalize_session` teardown;gateway 关闭时可只 finalize 而不 reset。忽略返回值。 | 按 surface:`session_id`, `platform`,可选 `reason`, `old_session_id`, `new_session_id` | Session 和 routing 标识。 | | `on_session_reset` | 观察者 | CLI/TUI session boundary,或 gateway 创建替代 session 后;忽略返回值。 | CLI:`session_id`, `platform`, `reason`;TUI:`session_id`, `platform`;gateway:另有 `reason`, `old_session_id`, `new_session_id` | Session 和 routing 标识。 | +| `on_session_delete` | 观察者 | 会话行删除提交后(单删、批量、empty-sweep、prune、ghost prune);忽略返回值。 | `session_id`,可选 `deleted_ids`(删除数不为 1 时为全集)、`reason`、`sessions_dir` | 仅 session 标识;无消息内容。 | | `on_skill_lifecycle` | 观察者 | 权威 skill 使用状态变更后;忽略返回值。 | `action`, `skill_name`, `provenance`, `task_id`, `session_id`, `use_count`, `reused`, `reuse_after_patch` | 暴露本地 skill 名和 provenance。 | | `subagent_start` | 观察者 | 子 agent 已构造、即将运行;忽略返回值。 | `parent_session_id`, `parent_turn_id`, `parent_subagent_id`, `child_session_id`, `child_subagent_id`, `child_role`, `child_goal` | Child goal 可能含用户/项目内容。 | | `subagent_stop` | 观察者 | 子 agent 退出;忽略返回值。 | `parent_session_id`, `parent_turn_id`, `child_session_id`, `child_role`, `child_summary`, `child_status`, `tool_call_history`, `duration_ms` | Summary 和已脱敏 tool-history metadata 仍可能暴露项目结构。 |