mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(sessions): export delegate cascade before deletion
This commit is contained in:
parent
e0dfcf275a
commit
c1fb170449
5 changed files with 217 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue