fix(sessions): export delegate cascade before deletion

This commit is contained in:
Drexuxux 2026-07-25 04:27:00 +03:00 committed by kshitij
parent e0dfcf275a
commit c1fb170449
5 changed files with 217 additions and 25 deletions

View file

@ -16340,10 +16340,10 @@ def main():
return
output_dir = Path(args.output).expanduser() if args.output else get_hermes_home() / "session-exports"
def _export_one(session_id: str):
def _export_one(session_id: str, *, include_lineage: bool = False):
data = (
db.export_session_lineage(session_id)
if getattr(args, "lineage", "single") == "logical"
if include_lineage
else db.export_session(session_id)
)
if not data:
@ -16373,30 +16373,85 @@ def main():
print(f"Session '{args.session_id}' not found.")
db.close()
return
try:
data, exported_path = _export_one(resolved_session_id)
except FileExistsError as e:
print(f"Export already exists: {e}. Pass --force to overwrite.")
db.close()
return
if not data or not exported_path:
print(f"Session '{args.session_id}' not found.")
db.close()
return
message_count = len(data.get("messages") or [])
suffix = "" if message_count == 1 else "s"
print(f"Exported 1 session ({message_count} message{suffix}) to {exported_path}")
delete_target_ids = [resolved_session_id]
if args.delete_after_verified:
ok, reason = verify_export_file(exported_path, data)
if not ok:
print(f"Export verification failed; not deleting: {reason}")
delete_target_ids = db.get_session_delete_targets(
resolved_session_id
)
exported_items = []
for target_id in delete_target_ids:
try:
data, exported_path = _export_one(
target_id,
include_lineage=(
target_id == resolved_session_id
and getattr(args, "lineage", "single") == "logical"
),
)
except FileExistsError as e:
print(
f"Export already exists: {e}. "
"Pass --force to overwrite."
)
db.close()
return
if not data or not exported_path:
print(
f"Session '{target_id}' disappeared during export; "
"nothing was deleted."
)
db.close()
return
exported_items.append((data, exported_path))
message_count = sum(
len(data.get("messages") or [])
for data, _path in exported_items
)
suffix = "" if message_count == 1 else "s"
if len(exported_items) == 1:
print(
f"Exported 1 session ({message_count} message{suffix}) "
f"to {exported_items[0][1]}"
)
else:
print(
f"Exported {len(exported_items)} sessions "
f"({message_count} message{suffix}) to {output_dir}"
)
if args.delete_after_verified:
for data, exported_path in exported_items:
ok, reason = verify_export_file(exported_path, data)
if not ok:
print(
"Export verification failed; not deleting "
f"session '{data.get('id')}': {reason}"
)
db.close()
return
sessions_dir = get_hermes_home() / "sessions"
if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
print(f"Deleted exported session '{resolved_session_id}'.")
if db.delete_session(
resolved_session_id,
sessions_dir=sessions_dir,
expected_delete_ids=delete_target_ids,
):
delegate_count = len(delete_target_ids) - 1
delegate_suffix = (
""
if not delegate_count
else f" and {delegate_count} delegate session"
f"{'' if delegate_count == 1 else 's'}"
)
print(
f"Deleted exported session '{resolved_session_id}'"
f"{delegate_suffix}."
)
else:
print(f"Exported, but session '{resolved_session_id}' was not deleted because it was not found.")
print(
f"Exported, but session '{resolved_session_id}' was "
"not deleted because its delegate set changed."
)
db.close()
return
@ -16422,7 +16477,10 @@ def main():
exported = 0
for row in candidates:
try:
data, exported_path = _export_one(row["id"])
data, exported_path = _export_one(
row["id"],
include_lineage=getattr(args, "lineage", "single") == "logical",
)
except FileExistsError as e:
print(f"Skipping existing export: {e}. Pass --force to overwrite.")
continue

View file

@ -8959,10 +8959,28 @@ class SessionDB:
except OSError:
pass
def get_session_delete_targets(self, session_id: str) -> List[str]:
"""Return every session row that :meth:`delete_session` would remove.
The requested session is first, followed by its recursively discovered
delegate/subagent children. Branch and compression children are not
included because deletion preserves them by orphaning their parent
reference.
"""
with self._lock:
exists = self._conn.execute(
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)
).fetchone()
if not exists:
return []
delegate_ids = _collect_delegate_child_ids(self._conn, [session_id])
return [session_id, *sorted(delegate_ids)]
def delete_session(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
expected_delete_ids: Optional[List[str]] = None,
) -> bool:
"""Delete a session and all its messages.
@ -8972,9 +8990,16 @@ class SessionDB:
(``parent_session_id NULL``) so they remain accessible independently.
When *sessions_dir* is provided, also removes on-disk transcript
files (``.json`` / ``.jsonl`` / ``request_dump_*``) for every deleted
session. Returns True if the session was found and deleted.
session. When *expected_delete_ids* is provided, deletion proceeds only
if the parent plus delegate cascade still matches that exact set. This
lets export-before-delete callers fail closed if a new delegate appears
after they materialize their archive. Returns True if the session was
found and deleted.
"""
removed_delegate_ids: List[str] = []
expected_ids = (
set(expected_delete_ids) if expected_delete_ids is not None else None
)
def _do(conn):
cursor = conn.execute(
@ -8982,6 +9007,13 @@ class SessionDB:
)
if cursor.fetchone()[0] == 0:
return False
if expected_ids is not None:
actual_ids = {
session_id,
*_collect_delegate_child_ids(conn, [session_id]),
}
if actual_ids != expected_ids:
return False
removed_delegate_ids.extend(_delete_delegate_children(conn, [session_id]))
# Orphan remaining child sessions (branches, etc.) so FK is satisfied.
conn.execute(

View file

@ -336,8 +336,12 @@ def test_sessions_export_md_delete_after_verified_deletes_after_file_check(monke
def export_session(self, session_id):
return {"id": "s1", "title": "Delete", "message_count": 1, "messages": [{"role": "user", "content": "safe"}]}
def get_session_delete_targets(self, session_id):
return [session_id]
def delete_session(self, session_id, **kwargs):
captured["deleted"] = session_id
captured["expected_delete_ids"] = kwargs["expected_delete_ids"]
return True
def close(self):
@ -363,11 +367,76 @@ def test_sessions_export_md_delete_after_verified_deletes_after_file_check(monke
main_mod.main()
assert captured == {"deleted": "s1"}
assert captured == {"deleted": "s1", "expected_delete_ids": ["s1"]}
assert len(list(tmp_path.glob("*.md"))) == 1
assert "Deleted exported session 's1'" in capsys.readouterr().out
def test_sessions_export_md_exports_delegate_cascade_before_deleting(
monkeypatch, tmp_path, capsys
):
import hermes_cli.main as main_mod
import hermes_state
db_path = tmp_path / "state.db"
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
real_session_db = hermes_state.SessionDB
db = real_session_db(db_path)
db.create_session("parent", "cli")
db.append_message("parent", "user", "parent transcript")
db.create_session(
"delegate",
"subagent",
parent_session_id="parent",
model_config={"_delegate_from": "parent"},
)
db.append_message("delegate", "assistant", "delegate-only result")
db.close()
(sessions_dir / "parent.jsonl").write_text("parent", encoding="utf-8")
(sessions_dir / "delegate.jsonl").write_text("delegate", encoding="utf-8")
monkeypatch.setattr(
hermes_state, "SessionDB", lambda: real_session_db(db_path)
)
monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
output_dir = tmp_path / "exports"
monkeypatch.setattr(
sys,
"argv",
[
"hermes",
"sessions",
"export",
"--format",
"md",
"--session-id",
"parent",
"--delete-after-verified",
"--yes",
str(output_dir),
],
)
main_mod.main()
exported = [
path.read_text(encoding="utf-8") for path in output_dir.glob("*.md")
]
assert len(exported) == 2
assert any("parent transcript" in text for text in exported)
assert any("delegate-only result" in text for text in exported)
check = real_session_db(db_path)
assert check.get_session("parent") is None
assert check.get_session("delegate") is None
check.close()
assert not (sessions_dir / "parent.jsonl").exists()
assert not (sessions_dir / "delegate.jsonl").exists()
output = capsys.readouterr().out
assert "Exported 2 sessions (2 messages)" in output
assert "and 1 delegate session" in output
def test_sessions_export_md_accepts_duration_age_grammar(monkeypatch, tmp_path, capsys):
"""--older-than accepts the same AGE grammar as prune ('2w', '5h', ISO)."""
import hermes_cli.main as main_mod

View file

@ -4818,6 +4818,39 @@ class TestListSessionsRich:
assert db.get_session("delegate") is None
assert db.get_session("branch") is not None
def test_delete_session_expected_targets_fail_closed_on_new_delegate(self, db):
db.create_session("parent", "cli")
db.create_session(
"delegate",
"cli",
parent_session_id="parent",
model_config={"_delegate_from": "parent"},
)
db.create_session(
"branch",
"cli",
parent_session_id="parent",
model_config={"_branched_from": "parent"},
)
expected_ids = db.get_session_delete_targets("parent")
assert expected_ids == ["parent", "delegate"]
db.create_session(
"late-delegate",
"cli",
parent_session_id="parent",
model_config={"_delegate_from": "parent"},
)
assert (
db.delete_session("parent", expected_delete_ids=expected_ids) is False
)
assert db.get_session("parent") is not None
assert db.get_session("delegate") is not None
assert db.get_session("late-delegate") is not None
assert db.get_session("branch") is not None
def test_v16_migration_tags_linked_delegate_rows(self, tmp_path):
"""Pre-marker linked subagent rows get tagged, then cascade with parent."""
import json

View file

@ -392,7 +392,7 @@ hermes sessions export --format md --model sonnet --min-messages 50 --redact
hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes
```
Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share.
Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. Because deleting a parent session also removes its delegate/subagent sessions, this mode exports and verifies each delegate in a separate file before deleting anything. If the delegate set changes during export, deletion is refused. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share.
### Delete a Session