1667 lines
56 KiB
Python
1667 lines
56 KiB
Python
"""Regression coverage for delegated subagent cleanup on CLI-session delete."""
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def _seed_session(
|
|
conn, sid, *, parent_id=None, model_config=None, source=None,
|
|
end_reason=None, ended_at=None, started_at="2026-07-11T00:00:00Z"
|
|
):
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO sessions (
|
|
id, title, started_at, source, parent_session_id, model_config,
|
|
end_reason, ended_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
sid,
|
|
sid,
|
|
started_at,
|
|
source or ("subagent" if (model_config or {}).get("_delegate_from") else "cli"),
|
|
parent_id,
|
|
json.dumps(model_config) if model_config else None,
|
|
end_reason,
|
|
ended_at,
|
|
),
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO messages (session_id, role, content, timestamp)
|
|
VALUES (?, 'user', ?, '2026-07-11T00:00:01Z')
|
|
""",
|
|
(sid, f"message for {sid}"),
|
|
)
|
|
|
|
|
|
def _seed_transcript_artifacts(hermes_home, session_ids):
|
|
sessions_dir = hermes_home / "sessions"
|
|
sessions_dir.mkdir(exist_ok=True)
|
|
for sid in session_ids:
|
|
(sessions_dir / f"{sid}.json").write_text("{}", encoding="utf-8")
|
|
(sessions_dir / f"{sid}.jsonl").write_text("{}\n", encoding="utf-8")
|
|
(sessions_dir / f"request_dump_{sid}_1.json").write_text("{}", encoding="utf-8")
|
|
return sessions_dir
|
|
|
|
|
|
def _remaining_artifact_session_ids(sessions_dir):
|
|
names = {path.name for path in sessions_dir.iterdir()}
|
|
ids = set()
|
|
for name in names:
|
|
if name.startswith("request_dump_"):
|
|
ids.add(name.removeprefix("request_dump_").rsplit("_", 1)[0])
|
|
else:
|
|
ids.add(name.rsplit(".", 1)[0])
|
|
return ids
|
|
|
|
|
|
def _assert_all_artifacts_exist(sessions_dir, session_ids):
|
|
"""Verify each preserved session keeps every supported artifact kind."""
|
|
for sid in session_ids:
|
|
assert (sessions_dir / f"{sid}.json").exists()
|
|
assert (sessions_dir / f"{sid}.jsonl").exists()
|
|
assert (sessions_dir / f"request_dump_{sid}_1.json").exists()
|
|
|
|
|
|
@pytest.mark.requires_agent_modules
|
|
def test_delete_cli_session_cascades_delegates_but_preserves_branch(tmp_path, monkeypatch):
|
|
"""Current Hermes removes delegates while preserving all other child kinds."""
|
|
hermes_state = pytest.importorskip("hermes_state")
|
|
SessionDB = hermes_state.SessionDB
|
|
|
|
state_db = tmp_path / "state.db"
|
|
db = SessionDB(db_path=state_db)
|
|
db.close()
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
_seed_session(conn, "parent")
|
|
_seed_session(
|
|
conn,
|
|
"delegate-child",
|
|
parent_id="parent",
|
|
model_config={"_delegate_from": "parent"},
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"delegate-grandchild",
|
|
parent_id="delegate-child",
|
|
model_config={"_delegate_from": "delegate-child"},
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"ordinary-branch",
|
|
parent_id="parent",
|
|
model_config={"_branched_from": "parent"},
|
|
source="subagent",
|
|
)
|
|
_seed_session(conn, "generic-child", parent_id="parent")
|
|
_seed_session(conn, "compression-parent", end_reason="compression")
|
|
_seed_session(
|
|
conn, "compression-child", parent_id="compression-parent",
|
|
source="subagent",
|
|
model_config={"_delegate_from": "compression-parent"},
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(
|
|
tmp_path,
|
|
{
|
|
"parent", "delegate-child", "delegate-grandchild", "ordinary-branch",
|
|
"generic-child", "compression-parent", "compression-child",
|
|
},
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
import api.profiles
|
|
|
|
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
|
|
|
|
from api.models import delete_cli_session
|
|
|
|
assert delete_cli_session("parent") is True
|
|
assert delete_cli_session("compression-parent") is True
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
remaining_sessions = {
|
|
row[0]: row[1]
|
|
for row in conn.execute(
|
|
"SELECT id, parent_session_id FROM sessions "
|
|
"WHERE id IN (?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
"parent", "delegate-child", "delegate-grandchild",
|
|
"ordinary-branch", "generic-child", "compression-parent",
|
|
"compression-child",
|
|
),
|
|
)
|
|
}
|
|
remaining_messages = {
|
|
row[0]
|
|
for row in conn.execute(
|
|
"SELECT DISTINCT session_id FROM messages "
|
|
"WHERE session_id IN (?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
"parent", "delegate-child", "delegate-grandchild",
|
|
"ordinary-branch", "generic-child", "compression-parent",
|
|
"compression-child",
|
|
),
|
|
)
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
assert remaining_sessions == {
|
|
"ordinary-branch": None,
|
|
"compression-child": None,
|
|
"generic-child": None,
|
|
}
|
|
assert remaining_messages == set(remaining_sessions)
|
|
assert _remaining_artifact_session_ids(sessions_dir) == set(remaining_sessions)
|
|
assert delete_cli_session("parent") is True
|
|
|
|
|
|
def test_delete_cli_session_cascades_marked_and_legacy_subagents(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""The scoped transaction keeps the delegated cascade invariant."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
started_at TEXT,
|
|
source TEXT,
|
|
parent_session_id TEXT,
|
|
model_config TEXT,
|
|
end_reason TEXT,
|
|
ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT,
|
|
role TEXT,
|
|
content TEXT,
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "parent")
|
|
_seed_session(
|
|
conn, "marked-child", parent_id="parent",
|
|
model_config={"_delegate_from": "parent"},
|
|
)
|
|
_seed_session(conn, "legacy-child", parent_id="parent", source="subagent")
|
|
_seed_session(
|
|
conn, "delegate-grandchild", parent_id="legacy-child",
|
|
model_config={"_delegate_from": "legacy-child"},
|
|
)
|
|
_seed_session(
|
|
conn, "branch-child", parent_id="parent",
|
|
model_config={"_branched_from": "parent"},
|
|
source="subagent",
|
|
)
|
|
_seed_session(conn, "generic-child", parent_id="parent")
|
|
_seed_session(conn, "compression-parent", end_reason="compression")
|
|
_seed_session(
|
|
conn, "compression-child", parent_id="compression-parent",
|
|
source="subagent",
|
|
model_config={"_delegate_from": "compression-parent"},
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(
|
|
tmp_path,
|
|
{
|
|
"parent", "marked-child", "legacy-child", "delegate-grandchild",
|
|
"branch-child", "generic-child", "compression-parent",
|
|
"compression-child",
|
|
},
|
|
)
|
|
import api.profiles
|
|
|
|
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
|
|
|
|
from api.models import delete_cli_session
|
|
|
|
assert delete_cli_session("parent") is True
|
|
assert delete_cli_session("compression-parent") is True
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
remaining = dict(
|
|
conn.execute("SELECT id, parent_session_id FROM sessions ORDER BY id")
|
|
)
|
|
remaining_messages = {
|
|
row[0] for row in conn.execute("SELECT DISTINCT session_id FROM messages")
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
assert remaining == {
|
|
"branch-child": None,
|
|
"compression-child": None,
|
|
"generic-child": None,
|
|
}
|
|
assert remaining_messages == set(remaining)
|
|
assert _remaining_artifact_session_ids(sessions_dir) == set(remaining)
|
|
assert delete_cli_session("parent") is True
|
|
|
|
|
|
def test_delete_cli_session_preserves_ambiguous_and_inherited_marker_branches(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Destructive classification must fail closed for branch evidence."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
started_at TEXT,
|
|
source TEXT,
|
|
parent_session_id TEXT,
|
|
model_config TEXT,
|
|
end_reason TEXT,
|
|
ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT,
|
|
role TEXT,
|
|
content TEXT,
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "delegate-parent")
|
|
_seed_session(
|
|
conn,
|
|
"both-marker-branch",
|
|
parent_id="delegate-parent",
|
|
source="subagent",
|
|
model_config={
|
|
"_delegate_from": "delegate-parent",
|
|
"_branched_from": "delegate-parent",
|
|
},
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"nested-branch-child",
|
|
parent_id="both-marker-branch",
|
|
source="subagent",
|
|
model_config={"_delegate_from": "delegate-parent"},
|
|
)
|
|
_seed_session(conn, "other-lineage")
|
|
_seed_session(
|
|
conn,
|
|
"mismatched-lineage-child",
|
|
parent_id="delegate-parent",
|
|
source="subagent",
|
|
model_config={"_delegate_from": "other-lineage"},
|
|
)
|
|
_seed_session(
|
|
conn, "corrupt-config-child", parent_id="delegate-parent", source="subagent"
|
|
)
|
|
conn.execute(
|
|
"UPDATE sessions SET model_config = ? WHERE id = ?",
|
|
('{"_delegate_from":"other-lineage"', "corrupt-config-child"),
|
|
)
|
|
_seed_session(
|
|
conn, "scalar-config-child", parent_id="delegate-parent", source="subagent"
|
|
)
|
|
conn.execute(
|
|
"UPDATE sessions SET model_config = ? WHERE id = ?",
|
|
('"not-a-dict"', "scalar-config-child"),
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"missing-time-parent",
|
|
end_reason="branched",
|
|
ended_at=None,
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"missing-time-branch",
|
|
parent_id="missing-time-parent",
|
|
source="subagent",
|
|
started_at=None,
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"malformed-time-parent",
|
|
end_reason="branched",
|
|
ended_at="not-a-timestamp",
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"malformed-time-branch",
|
|
parent_id="malformed-time-parent",
|
|
source="subagent",
|
|
started_at="also-not-a-timestamp",
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"nonfinite-time-parent",
|
|
end_reason="branched",
|
|
ended_at="NaN",
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"nonfinite-time-branch",
|
|
parent_id="nonfinite-time-parent",
|
|
source="subagent",
|
|
started_at="1783728001.0",
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"nonfinite-child-parent",
|
|
end_reason="branched",
|
|
ended_at="1783728000.0",
|
|
)
|
|
for suffix, started_at in (
|
|
("nan", "NaN"),
|
|
("positive-infinity", "Infinity"),
|
|
("negative-infinity", "-Infinity"),
|
|
):
|
|
_seed_session(
|
|
conn,
|
|
f"nonfinite-{suffix}-branch",
|
|
parent_id="nonfinite-child-parent",
|
|
source="subagent",
|
|
started_at=started_at,
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"naive-time-parent",
|
|
end_reason="branched",
|
|
ended_at="2026-07-11T00:00:00",
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"naive-time-branch",
|
|
parent_id="naive-time-parent",
|
|
source="subagent",
|
|
started_at="2026-07-10T23:59:59Z",
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"parsed-time-parent",
|
|
end_reason="branched",
|
|
ended_at="1783728000.0",
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"parsed-time-branch",
|
|
parent_id="parsed-time-parent",
|
|
source="subagent",
|
|
started_at="2026-07-11T00:00:01Z",
|
|
)
|
|
_seed_session(
|
|
conn,
|
|
"parsed-time-delegate",
|
|
parent_id="parsed-time-parent",
|
|
source="subagent",
|
|
started_at="1783727999.0",
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
preserved = {
|
|
"both-marker-branch",
|
|
"nested-branch-child",
|
|
"other-lineage",
|
|
"mismatched-lineage-child",
|
|
"corrupt-config-child",
|
|
"scalar-config-child",
|
|
"missing-time-branch",
|
|
"malformed-time-branch",
|
|
"nonfinite-time-branch",
|
|
"nonfinite-nan-branch",
|
|
"nonfinite-positive-infinity-branch",
|
|
"nonfinite-negative-infinity-branch",
|
|
"naive-time-branch",
|
|
"parsed-time-branch",
|
|
}
|
|
sessions_dir = _seed_transcript_artifacts(
|
|
tmp_path,
|
|
preserved
|
|
| {
|
|
"delegate-parent",
|
|
"missing-time-parent",
|
|
"malformed-time-parent",
|
|
"nonfinite-time-parent",
|
|
"nonfinite-child-parent",
|
|
"naive-time-parent",
|
|
"parsed-time-parent",
|
|
"parsed-time-delegate",
|
|
},
|
|
)
|
|
import api.profiles
|
|
|
|
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
|
|
|
|
from api.models import delete_cli_session
|
|
|
|
assert delete_cli_session("delegate-parent") is True
|
|
assert delete_cli_session("missing-time-parent") is True
|
|
assert delete_cli_session("malformed-time-parent") is True
|
|
assert delete_cli_session("nonfinite-time-parent") is True
|
|
assert delete_cli_session("nonfinite-child-parent") is True
|
|
assert delete_cli_session("naive-time-parent") is True
|
|
assert delete_cli_session("parsed-time-parent") is True
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
remaining = dict(
|
|
conn.execute("SELECT id, parent_session_id FROM sessions ORDER BY id")
|
|
)
|
|
remaining_messages = {
|
|
row[0] for row in conn.execute("SELECT DISTINCT session_id FROM messages")
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
assert remaining["nested-branch-child"] == "both-marker-branch"
|
|
assert {
|
|
sid: parent_id
|
|
for sid, parent_id in remaining.items()
|
|
if sid != "nested-branch-child"
|
|
} == {sid: None for sid in preserved - {"nested-branch-child"}}
|
|
assert remaining_messages == preserved
|
|
assert _remaining_artifact_session_ids(sessions_dir) == preserved
|
|
_assert_all_artifacts_exist(sessions_dir, preserved)
|
|
|
|
|
|
def test_delete_cli_session_profile_resolution_failure_does_not_guess_fallback(
|
|
tmp_path, monkeypatch
|
|
):
|
|
fallback_home = tmp_path / "fallback-profile"
|
|
fallback_home.mkdir()
|
|
state_db = fallback_home / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (id TEXT PRIMARY KEY);
|
|
CREATE TABLE messages (session_id TEXT);
|
|
INSERT INTO sessions (id) VALUES ('victim');
|
|
INSERT INTO messages (session_id) VALUES ('victim');
|
|
"""
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
import api.profiles
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(fallback_home))
|
|
|
|
def _resolution_failure():
|
|
raise RuntimeError("active profile unavailable")
|
|
|
|
monkeypatch.setattr(api.profiles, "get_active_hermes_home", _resolution_failure)
|
|
|
|
from api.models import delete_cli_session
|
|
|
|
assert delete_cli_session("victim") is False
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
assert conn.execute("SELECT id FROM sessions").fetchall() == [("victim",)]
|
|
assert conn.execute("SELECT session_id FROM messages").fetchall() == [
|
|
("victim",)
|
|
]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.parametrize("sid", ["victim", "_victim", "-victim"])
|
|
def test_delete_cli_session_reports_artifact_cleanup_failure(
|
|
tmp_path, monkeypatch, sid
|
|
):
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
started_at TEXT,
|
|
source TEXT,
|
|
parent_session_id TEXT,
|
|
model_config TEXT,
|
|
end_reason TEXT,
|
|
ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT,
|
|
role TEXT,
|
|
content TEXT,
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, sid)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(tmp_path, {sid})
|
|
locked_artifact = sessions_dir / f"{sid}.json"
|
|
locked_content = locked_artifact.read_text(encoding="utf-8")
|
|
original_unlink = Path.unlink
|
|
|
|
def _locked_unlink(path, *args, **kwargs):
|
|
if path == locked_artifact:
|
|
raise PermissionError("locked")
|
|
return original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", _locked_unlink)
|
|
import api.profiles
|
|
|
|
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
|
|
|
|
from api.models import delete_cli_session
|
|
|
|
assert delete_cli_session(sid) is False
|
|
assert locked_artifact.read_text(encoding="utf-8") == locked_content
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
assert conn.execute("SELECT id FROM sessions").fetchall() == []
|
|
assert conn.execute("SELECT session_id FROM messages").fetchall() == []
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_delete_cli_session_deletes_explicit_migrated_delegate_without_source_tag(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Explicit lineage is authoritative; source gates only legacy inference."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
started_at TEXT,
|
|
source TEXT,
|
|
parent_session_id TEXT,
|
|
model_config TEXT,
|
|
end_reason TEXT,
|
|
ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT,
|
|
role TEXT,
|
|
content TEXT,
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "parent")
|
|
# Agent-migrated delegates can carry the authoritative marker without
|
|
# retaining the historical source='subagent' tag.
|
|
_seed_session(
|
|
conn, "migrated-delegate", parent_id="parent",
|
|
source="cli",
|
|
model_config={"_delegate_from": "parent"},
|
|
)
|
|
# Marker-less legacy inference still requires source='subagent'.
|
|
_seed_session(conn, "legacy-delegate", parent_id="parent", source="subagent")
|
|
_seed_session(conn, "webui-child", parent_id="parent", source="webui")
|
|
_seed_session(conn, "cli-child", parent_id="parent", source="cli")
|
|
# Branch evidence continues to take precedence over an explicit marker.
|
|
_seed_session(
|
|
conn, "migrated-branch", parent_id="parent", source="cli",
|
|
model_config={
|
|
"_delegate_from": "parent",
|
|
"_branched_from": "parent",
|
|
},
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(
|
|
tmp_path,
|
|
{
|
|
"parent", "migrated-delegate", "legacy-delegate",
|
|
"webui-child", "cli-child", "migrated-branch",
|
|
},
|
|
)
|
|
import api.profiles
|
|
|
|
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
|
|
|
|
from api.models import delete_cli_session
|
|
|
|
assert delete_cli_session("parent") is True
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
remaining = dict(
|
|
conn.execute("SELECT id, parent_session_id FROM sessions ORDER BY id")
|
|
)
|
|
remaining_messages = {
|
|
row[0] for row in conn.execute("SELECT DISTINCT session_id FROM messages")
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
preserved = {"webui-child", "cli-child", "migrated-branch"}
|
|
assert remaining == {sid: None for sid in preserved}
|
|
assert remaining_messages == preserved
|
|
assert _remaining_artifact_session_ids(sessions_dir) == preserved
|
|
_assert_all_artifacts_exist(sessions_dir, preserved)
|
|
|
|
|
|
def test_delete_cli_session_serializes_manifest_lifecycle_across_processes(
|
|
tmp_path
|
|
):
|
|
"""Two workers must serialize both early recovery and late artifact cleanup."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "trigger-a")
|
|
_seed_session(conn, "trigger-b")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(
|
|
tmp_path, {"trigger-a", "trigger-b", "orphan-a", "orphan-b"}
|
|
)
|
|
(sessions_dir / ".cleanup_manifest_orphan-a.json").write_text(
|
|
json.dumps(["orphan-a"]), encoding="utf-8"
|
|
)
|
|
(sessions_dir / ".cleanup_manifest_orphan-b.json").write_text(
|
|
json.dumps(["orphan-b"]), encoding="utf-8"
|
|
)
|
|
|
|
go = tmp_path / "go"
|
|
active = tmp_path / "manifest-recovery-active"
|
|
violation = tmp_path / "manifest-recovery-overlap"
|
|
artifact_active = tmp_path / "artifact-cleanup-active"
|
|
artifact_violation = tmp_path / "artifact-cleanup-overlap"
|
|
script = r"""
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from api import models
|
|
from api import profiles
|
|
|
|
home = Path(sys.argv[1])
|
|
sid = sys.argv[2]
|
|
ready = Path(sys.argv[3])
|
|
go = Path(sys.argv[4])
|
|
active = Path(sys.argv[5])
|
|
violation = Path(sys.argv[6])
|
|
artifact_active = Path(sys.argv[7])
|
|
artifact_violation = Path(sys.argv[8])
|
|
artifact_seen = Path(sys.argv[9])
|
|
original = models._process_stale_cleanup_manifests
|
|
original_unlink = Path.unlink
|
|
profiles.get_active_hermes_home = lambda: home
|
|
|
|
def observed_recovery(hermes_home):
|
|
owns_marker = False
|
|
try:
|
|
fd = os.open(active, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
except FileExistsError:
|
|
violation.write_text("overlap", encoding="utf-8")
|
|
else:
|
|
os.close(fd)
|
|
owns_marker = True
|
|
try:
|
|
time.sleep(0.5)
|
|
return original(hermes_home)
|
|
finally:
|
|
if owns_marker:
|
|
active.unlink(missing_ok=True)
|
|
|
|
models._process_stale_cleanup_manifests = observed_recovery
|
|
|
|
artifact_phase_entered = False
|
|
|
|
def observed_unlink(path, *args, **kwargs):
|
|
global artifact_phase_entered
|
|
is_current_artifact = (
|
|
path.parent == home / "sessions"
|
|
and path.name in {f"{sid}.json", f"{sid}.jsonl"}
|
|
)
|
|
if not is_current_artifact or artifact_phase_entered:
|
|
return original_unlink(path, *args, **kwargs)
|
|
|
|
artifact_phase_entered = True
|
|
artifact_seen.write_text("seen", encoding="utf-8")
|
|
owns_marker = False
|
|
try:
|
|
fd = os.open(artifact_active, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
except FileExistsError:
|
|
artifact_violation.write_text("overlap", encoding="utf-8")
|
|
else:
|
|
os.close(fd)
|
|
owns_marker = True
|
|
try:
|
|
# Keep the late phase open longer than the serialized recovery probe
|
|
# above. If a future refactor releases the process lock after recovery,
|
|
# the second worker must reach this window and trip the overlap marker.
|
|
time.sleep(2.0)
|
|
return original_unlink(path, *args, **kwargs)
|
|
finally:
|
|
if owns_marker:
|
|
original_unlink(artifact_active, missing_ok=True)
|
|
|
|
Path.unlink = observed_unlink
|
|
ready.write_text("ready", encoding="utf-8")
|
|
deadline = time.monotonic() + 15
|
|
while not go.exists():
|
|
if time.monotonic() >= deadline:
|
|
raise SystemExit("start barrier timeout")
|
|
time.sleep(0.01)
|
|
print(f"RESULT={models.delete_cli_session(sid)}", flush=True)
|
|
"""
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
env = os.environ.copy()
|
|
env["HERMES_HOME"] = str(tmp_path)
|
|
env["HERMES_WEBUI_ISOLATED_PROFILE"] = "1"
|
|
env["PYTHONPATH"] = os.pathsep.join(
|
|
part for part in (str(repo_root), env.get("PYTHONPATH", "")) if part
|
|
)
|
|
procs = []
|
|
ready_paths = []
|
|
artifact_seen_paths = []
|
|
for suffix in ("a", "b"):
|
|
ready = tmp_path / f"ready-{suffix}"
|
|
ready_paths.append(ready)
|
|
artifact_seen = tmp_path / f"artifact-seen-{suffix}"
|
|
artifact_seen_paths.append(artifact_seen)
|
|
procs.append(
|
|
subprocess.Popen(
|
|
[
|
|
sys.executable, "-c", script, str(tmp_path),
|
|
f"trigger-{suffix}", str(ready), str(go), str(active),
|
|
str(violation), str(artifact_active), str(artifact_violation),
|
|
str(artifact_seen),
|
|
],
|
|
cwd=repo_root,
|
|
env=env,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
)
|
|
|
|
deadline = time.monotonic() + 15
|
|
while not all(path.exists() for path in ready_paths):
|
|
if time.monotonic() >= deadline:
|
|
for proc in procs:
|
|
proc.kill()
|
|
raise AssertionError("worker readiness timeout")
|
|
time.sleep(0.01)
|
|
go.write_text("go", encoding="utf-8")
|
|
|
|
outputs = [proc.communicate(timeout=20) for proc in procs]
|
|
for proc, (stdout, stderr) in zip(procs, outputs, strict=False):
|
|
assert proc.returncode == 0, (stdout, stderr)
|
|
assert "RESULT=True" in stdout, (stdout, stderr)
|
|
assert not violation.exists(), "stale-manifest recovery overlapped across processes"
|
|
assert all(path.exists() for path in artifact_seen_paths), (
|
|
"both workers must reach post-commit artifact cleanup"
|
|
)
|
|
assert not artifact_violation.exists(), (
|
|
"post-commit artifact cleanup overlapped across processes"
|
|
)
|
|
assert not list(sessions_dir.glob(".cleanup_manifest_*.json"))
|
|
for sid in ("trigger-a", "trigger-b", "orphan-a", "orphan-b"):
|
|
assert not (sessions_dir / f"{sid}.json").exists()
|
|
assert not (sessions_dir / f"{sid}.jsonl").exists()
|
|
|
|
|
|
def test_cleanup_manifest_process_lock_uses_windows_byte_lock(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""The native-Windows fallback locks and unlocks one persistent byte."""
|
|
import api.models as models
|
|
|
|
calls = []
|
|
|
|
class FakeMsvcrt:
|
|
LK_LOCK = 1
|
|
LK_UNLCK = 2
|
|
|
|
@staticmethod
|
|
def locking(fd, mode, nbytes):
|
|
calls.append((fd, mode, nbytes))
|
|
|
|
monkeypatch.setattr(models, "_fcntl", None)
|
|
monkeypatch.setattr(models, "_msvcrt", FakeMsvcrt)
|
|
|
|
with pytest.raises(RuntimeError, match="probe"):
|
|
with models._cleanup_manifest_process_lock(tmp_path):
|
|
assert [call[1:] for call in calls] == [(FakeMsvcrt.LK_LOCK, 1)]
|
|
raise RuntimeError("probe")
|
|
|
|
assert [call[1:] for call in calls] == [
|
|
(FakeMsvcrt.LK_LOCK, 1),
|
|
(FakeMsvcrt.LK_UNLCK, 1),
|
|
]
|
|
lock_path = tmp_path / ".session_cleanup.lock"
|
|
assert lock_path.read_bytes() == b"\0"
|
|
|
|
|
|
def test_delete_cli_session_thread_lock_does_not_block_different_profiles(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""A stalled cleanup in one profile must not globally block another."""
|
|
homes = {
|
|
"profile-a": tmp_path / "profile-a",
|
|
"profile-b": tmp_path / "profile-b",
|
|
}
|
|
for name, home in homes.items():
|
|
home.mkdir()
|
|
conn = sqlite3.connect(home / "state.db")
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, name)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
_seed_transcript_artifacts(home, {name})
|
|
|
|
import api.models as models
|
|
import api.profiles as profiles
|
|
|
|
monkeypatch.setattr(
|
|
profiles,
|
|
"get_active_hermes_home",
|
|
lambda: homes[threading.current_thread().name],
|
|
)
|
|
original_recovery = models._process_stale_cleanup_manifests
|
|
profile_a_entered = threading.Event()
|
|
profile_b_entered = threading.Event()
|
|
release_profile_a = threading.Event()
|
|
|
|
def observed_recovery(hermes_home):
|
|
if Path(hermes_home) == homes["profile-a"]:
|
|
profile_a_entered.set()
|
|
if not release_profile_a.wait(timeout=10):
|
|
raise TimeoutError("profile-a release timeout")
|
|
else:
|
|
profile_b_entered.set()
|
|
return original_recovery(hermes_home)
|
|
|
|
monkeypatch.setattr(models, "_process_stale_cleanup_manifests", observed_recovery)
|
|
results = {}
|
|
|
|
def delete_for_profile(name):
|
|
results[name] = models.delete_cli_session(name)
|
|
|
|
thread_a = threading.Thread(target=delete_for_profile, args=("profile-a",), name="profile-a")
|
|
thread_b = threading.Thread(target=delete_for_profile, args=("profile-b",), name="profile-b")
|
|
thread_a.start()
|
|
assert profile_a_entered.wait(timeout=10)
|
|
thread_b.start()
|
|
try:
|
|
assert profile_b_entered.wait(timeout=3), (
|
|
"profile-b was globally blocked by profile-a's in-process lock"
|
|
)
|
|
thread_b.join(timeout=10)
|
|
assert not thread_b.is_alive()
|
|
assert results.get("profile-b") is True
|
|
finally:
|
|
release_profile_a.set()
|
|
thread_a.join(timeout=10)
|
|
thread_b.join(timeout=10)
|
|
|
|
assert not thread_a.is_alive()
|
|
assert not thread_b.is_alive()
|
|
assert results == {"profile-a": True, "profile-b": True}
|
|
|
|
|
|
def test_delete_cli_session_cleans_referential_tables(tmp_path, monkeypatch):
|
|
"""Deleting a session must also remove rows from session_model_usage,
|
|
compression_locks, and telegram_dm_topic_bindings — not just
|
|
sessions and messages.
|
|
"""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
started_at TEXT,
|
|
source TEXT,
|
|
parent_session_id TEXT,
|
|
model_config TEXT,
|
|
end_reason TEXT,
|
|
ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT,
|
|
role TEXT,
|
|
content TEXT,
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
CREATE TABLE session_model_usage (
|
|
session_id TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (session_id, model)
|
|
);
|
|
CREATE TABLE compression_locks (
|
|
session_id TEXT NOT NULL,
|
|
holder TEXT NOT NULL,
|
|
acquired_at REAL NOT NULL,
|
|
expires_at REAL NOT NULL
|
|
);
|
|
CREATE TABLE telegram_dm_topic_bindings (
|
|
chat_id TEXT NOT NULL,
|
|
thread_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
session_key TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
linked_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (chat_id, thread_id)
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "parent")
|
|
_seed_session(
|
|
conn, "delegate", parent_id="parent",
|
|
source="subagent",
|
|
model_config={"_delegate_from": "parent"},
|
|
)
|
|
# Populate referential tables for both parent and delegate.
|
|
conn.execute(
|
|
"INSERT INTO session_model_usage (session_id, model, input_tokens, output_tokens) "
|
|
"VALUES ('parent', 'test-model', 100, 50)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO session_model_usage (session_id, model, input_tokens, output_tokens) "
|
|
"VALUES ('delegate', 'test-model', 200, 75)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO compression_locks (session_id, holder, acquired_at, expires_at) "
|
|
"VALUES ('parent', 'compressor', 1000.0, 2000.0)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO compression_locks (session_id, holder, acquired_at, expires_at) "
|
|
"VALUES ('delegate', 'compressor', 1000.0, 2000.0)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO telegram_dm_topic_bindings "
|
|
"(chat_id, thread_id, user_id, session_key, session_id, linked_at, updated_at) "
|
|
"VALUES ('chat1', 'thread1', 'user1', 'key1', 'parent', 1000.0, 1000.0)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO telegram_dm_topic_bindings "
|
|
"(chat_id, thread_id, user_id, session_key, session_id, linked_at, updated_at) "
|
|
"VALUES ('chat2', 'thread2', 'user1', 'key2', 'delegate', 1000.0, 1000.0)"
|
|
)
|
|
# An unrelated session's rows must survive.
|
|
_seed_session(conn, "bystander")
|
|
conn.execute(
|
|
"INSERT INTO session_model_usage (session_id, model, input_tokens, output_tokens) "
|
|
"VALUES ('bystander', 'test-model', 10, 5)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO compression_locks (session_id, holder, acquired_at, expires_at) "
|
|
"VALUES ('bystander', 'compressor', 1000.0, 2000.0)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO telegram_dm_topic_bindings "
|
|
"(chat_id, thread_id, user_id, session_key, session_id, linked_at, updated_at) "
|
|
"VALUES ('chat3', 'thread3', 'user1', 'key3', 'bystander', 1000.0, 1000.0)"
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
_seed_transcript_artifacts(tmp_path, {"parent", "delegate", "bystander"})
|
|
import api.profiles
|
|
|
|
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
|
|
|
|
from api.models import delete_cli_session
|
|
|
|
assert delete_cli_session("parent") is True
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
# session_model_usage: only bystander survives.
|
|
smu = [
|
|
row[0]
|
|
for row in conn.execute(
|
|
"SELECT session_id FROM session_model_usage ORDER BY session_id"
|
|
)
|
|
]
|
|
assert smu == ["bystander"]
|
|
|
|
# compression_locks: only bystander survives.
|
|
cl = [
|
|
row[0]
|
|
for row in conn.execute(
|
|
"SELECT session_id FROM compression_locks ORDER BY session_id"
|
|
)
|
|
]
|
|
assert cl == ["bystander"]
|
|
|
|
# telegram_dm_topic_bindings: only bystander survives.
|
|
tdtb = [
|
|
row[0]
|
|
for row in conn.execute(
|
|
"SELECT session_id FROM telegram_dm_topic_bindings ORDER BY session_id"
|
|
)
|
|
]
|
|
assert tdtb == ["bystander"]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_delete_cli_session_artifact_cleanup_is_idempotent(tmp_path, monkeypatch):
|
|
"""When artifact cleanup partially fails, the manifest retains the
|
|
pending ID so a second call retries it. A second call that succeeds
|
|
must return True and leave no manifest behind.
|
|
"""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
started_at TEXT,
|
|
source TEXT,
|
|
parent_session_id TEXT,
|
|
model_config TEXT,
|
|
end_reason TEXT,
|
|
ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT,
|
|
role TEXT,
|
|
content TEXT,
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "victim")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(tmp_path, {"victim"})
|
|
locked_artifact = sessions_dir / "victim.json"
|
|
locked_content = locked_artifact.read_text(encoding="utf-8")
|
|
original_unlink = Path.unlink
|
|
|
|
call_count = {"n": 0}
|
|
|
|
def _locked_once_unlink(path, *args, **kwargs):
|
|
if path == locked_artifact:
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
raise PermissionError("locked on first attempt")
|
|
return original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", _locked_once_unlink)
|
|
import api.profiles
|
|
|
|
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))
|
|
|
|
from api.models import delete_cli_session
|
|
|
|
# First call: DB row is deleted but artifact cleanup fails → False.
|
|
assert delete_cli_session("victim") is False
|
|
assert locked_artifact.read_text(encoding="utf-8") == locked_content
|
|
|
|
# Manifest must still exist with "victim" pending.
|
|
manifests = sorted(sessions_dir.glob(".cleanup_manifest_*.json"))
|
|
assert len(manifests) == 1
|
|
assert json.loads(manifests[0].read_text(encoding="utf-8")) == ["victim"]
|
|
|
|
# DB row is already gone; second call returns True (idempotent —
|
|
# absence is success) and retries the artifact.
|
|
assert delete_cli_session("victim") is True
|
|
assert not locked_artifact.exists()
|
|
assert not list(sessions_dir.glob(".cleanup_manifest_*.json"))
|
|
|
|
|
|
def test_delete_cli_session_stale_manifest_preserves_live_session(tmp_path, monkeypatch):
|
|
"""A stale manifest from a failed commit must not unlink artifacts
|
|
for a session that still exists in the DB.
|
|
"""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "live-one")
|
|
_seed_session(conn, "live-two")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(tmp_path, {"live-one", "live-two"})
|
|
|
|
# Write a stale manifest claiming "live-one" artifacts should be
|
|
# removed, simulating a previous delete_cli_session whose commit
|
|
# failed after the manifest was written.
|
|
stale_manifest = sessions_dir / ".cleanup_manifest_stale-sim.json"
|
|
stale_manifest.write_text(
|
|
json.dumps(["live-one"]), encoding="utf-8"
|
|
)
|
|
|
|
import api.profiles
|
|
monkeypatch.setattr(
|
|
api.profiles, "get_active_hermes_home", lambda: str(tmp_path)
|
|
)
|
|
from api.models import delete_cli_session
|
|
|
|
# Delete a session NOT in the stale manifest.
|
|
assert delete_cli_session("live-two") is True
|
|
|
|
# _process_stale_cleanup_manifests ran at entry and should have
|
|
# skipped "live-one" because it's still alive.
|
|
assert (sessions_dir / "live-one.json").exists()
|
|
assert (sessions_dir / "live-one.jsonl").exists()
|
|
assert (sessions_dir / "request_dump_live-one_1.json").exists()
|
|
|
|
# "live-two" artifacts should be gone.
|
|
assert not (sessions_dir / "live-two.json").exists()
|
|
assert not (sessions_dir / "live-two.jsonl").exists()
|
|
|
|
# The stale manifest was processed and dropped (live-one is alive,
|
|
# so the manifest was identified as stale from a failed commit and
|
|
# silently removed). No manifests should linger.
|
|
assert not list(sessions_dir.glob(".cleanup_manifest_*.json"))
|
|
|
|
|
|
def test_delete_cli_session_concurrent_unique_manifests(tmp_path, monkeypatch):
|
|
"""Each delete call writes its own unique manifest file so concurrent
|
|
deletes never clobber each other's retry records.
|
|
"""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "parent-a")
|
|
_seed_session(
|
|
conn, "child-a", parent_id="parent-a", source="subagent",
|
|
model_config={"_delegate_from": "parent-a"},
|
|
)
|
|
_seed_session(conn, "parent-b")
|
|
_seed_session(
|
|
conn, "child-b", parent_id="parent-b", source="subagent",
|
|
model_config={"_delegate_from": "parent-b"},
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(
|
|
tmp_path, {"parent-a", "child-a", "parent-b", "child-b"}
|
|
)
|
|
|
|
import api.profiles
|
|
monkeypatch.setattr(
|
|
api.profiles, "get_active_hermes_home", lambda: str(tmp_path)
|
|
)
|
|
from api.models import delete_cli_session
|
|
|
|
# True concurrent delete: two threads synchronised by a Barrier so
|
|
# both enter the manifest critical section in parallel. The lock
|
|
# serialises access; both must still complete successfully.
|
|
import threading as _thr
|
|
|
|
barrier = _thr.Barrier(2)
|
|
results = []
|
|
|
|
def _del_a():
|
|
barrier.wait()
|
|
results.append(delete_cli_session("parent-a"))
|
|
|
|
def _del_b():
|
|
barrier.wait()
|
|
results.append(delete_cli_session("parent-b"))
|
|
|
|
t_a = _thr.Thread(target=_del_a)
|
|
t_b = _thr.Thread(target=_del_b)
|
|
t_a.start()
|
|
t_b.start()
|
|
t_a.join()
|
|
t_b.join()
|
|
|
|
assert results == [True, True], f"Both deletes must succeed: {results}"
|
|
|
|
# No manifests linger; all artifacts cleaned for every deleted session.
|
|
assert not list(sessions_dir.glob(".cleanup_manifest_*.json"))
|
|
for sid in ("parent-a", "child-a", "parent-b", "child-b"):
|
|
assert not (sessions_dir / f"{sid}.json").exists()
|
|
assert not (sessions_dir / f"{sid}.jsonl").exists()
|
|
|
|
|
|
def test_delete_cli_session_releases_manifest_lock_after_unlink_error(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""A stale-manifest exception must not deadlock every later delete."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "victim")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(tmp_path, {"victim"})
|
|
empty_manifest = sessions_dir / ".cleanup_manifest_empty.json"
|
|
empty_manifest.write_text("[]", encoding="utf-8")
|
|
|
|
import api.profiles
|
|
import api.models as models
|
|
|
|
monkeypatch.setattr(
|
|
api.profiles, "get_active_hermes_home", lambda: str(tmp_path)
|
|
)
|
|
original_unlink = Path.unlink
|
|
|
|
def _raise_for_manifest(path, *args, **kwargs):
|
|
if path == empty_manifest:
|
|
raise PermissionError("manifest is locked")
|
|
return original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", _raise_for_manifest)
|
|
assert models.delete_cli_session("victim") is False
|
|
|
|
# The context manager must release this profile's lock even though stale
|
|
# manifest cleanup raised before the DB transaction began.
|
|
manifest_lock = models._cleanup_manifest_thread_lock(tmp_path)
|
|
assert manifest_lock.acquire(blocking=False) is True
|
|
manifest_lock.release()
|
|
|
|
# Restore unlink behavior and prove a complete later delete can enter the
|
|
# same critical section and finish, rather than merely acquiring the lock.
|
|
monkeypatch.setattr(Path, "unlink", original_unlink)
|
|
empty_manifest.unlink()
|
|
assert models.delete_cli_session("victim") is True
|
|
assert not (sessions_dir / "victim.json").exists()
|
|
|
|
|
|
def test_delete_cli_session_missing_db_preserves_manifested_live_artifacts(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""A temporarily missing state.db is unknown, never proof of deletion."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "live-session")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(tmp_path, {"live-session"})
|
|
manifest = sessions_dir / ".cleanup_manifest_missing-db.json"
|
|
manifest.write_text(json.dumps(["live-session"]), encoding="utf-8")
|
|
hidden_db = tmp_path / "state.db.temporarily-hidden"
|
|
state_db.rename(hidden_db)
|
|
|
|
import api.profiles
|
|
from api.models import delete_cli_session
|
|
|
|
monkeypatch.setattr(
|
|
api.profiles, "get_active_hermes_home", lambda: str(tmp_path)
|
|
)
|
|
assert delete_cli_session("unrelated") is False
|
|
|
|
# Every artifact and the retry record survive while liveness is unknown.
|
|
assert (sessions_dir / "live-session.json").exists()
|
|
assert (sessions_dir / "live-session.jsonl").exists()
|
|
assert (sessions_dir / "request_dump_live-session_1.json").exists()
|
|
assert manifest.exists()
|
|
|
|
hidden_db.rename(state_db)
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
assert conn.execute(
|
|
"SELECT 1 FROM sessions WHERE id = ?", ("live-session",)
|
|
).fetchone() == (1,)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_delete_cli_session_unqueryable_db_preserves_manifested_live_artifacts(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""A failed liveness query preserves every artifact and reports failure."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "live-session")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(tmp_path, {"live-session"})
|
|
manifest = sessions_dir / ".cleanup_manifest_unqueryable-db.json"
|
|
manifest.write_text(json.dumps(["live-session"]), encoding="utf-8")
|
|
|
|
import api.profiles
|
|
import api.models as models
|
|
|
|
monkeypatch.setattr(
|
|
api.profiles, "get_active_hermes_home", lambda: str(tmp_path)
|
|
)
|
|
original_connect = sqlite3.connect
|
|
|
|
def _fail_readonly_connect(database, *args, **kwargs):
|
|
if kwargs.get("uri") is True and "mode=ro" in str(database):
|
|
raise sqlite3.OperationalError("state.db is temporarily unreadable")
|
|
return original_connect(database, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(sqlite3, "connect", _fail_readonly_connect)
|
|
assert models.delete_cli_session("unrelated") is False
|
|
|
|
assert manifest.exists()
|
|
_assert_all_artifacts_exist(sessions_dir, {"live-session"})
|
|
conn = original_connect(state_db)
|
|
try:
|
|
assert conn.execute(
|
|
"SELECT 1 FROM sessions WHERE id = ?", ("live-session",)
|
|
).fetchone() == (1,)
|
|
assert conn.execute(
|
|
"SELECT 1 FROM messages WHERE session_id = ?", ("live-session",)
|
|
).fetchone() == (1,)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_delete_cli_session_uses_delegate_lineage_parent_for_compression(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Authoritative delegate lineage preserves a compression continuation."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "compression-parent", end_reason="compression")
|
|
_seed_session(conn, "physical-parent")
|
|
_seed_session(
|
|
conn,
|
|
"continuation",
|
|
parent_id="physical-parent",
|
|
source="subagent",
|
|
model_config={"_delegate_from": "compression-parent"},
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(
|
|
tmp_path, {"compression-parent", "physical-parent", "continuation"}
|
|
)
|
|
|
|
import api.profiles
|
|
from api.models import delete_cli_session
|
|
|
|
monkeypatch.setattr(
|
|
api.profiles, "get_active_hermes_home", lambda: str(tmp_path)
|
|
)
|
|
assert delete_cli_session("compression-parent") is True
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
assert conn.execute(
|
|
"SELECT parent_session_id FROM sessions WHERE id = ?",
|
|
("continuation",),
|
|
).fetchone() == ("physical-parent",)
|
|
assert conn.execute(
|
|
"SELECT 1 FROM messages WHERE session_id = ?", ("continuation",)
|
|
).fetchone() == (1,)
|
|
finally:
|
|
conn.close()
|
|
_assert_all_artifacts_exist(sessions_dir, {"continuation"})
|
|
|
|
|
|
def test_delete_cli_session_manifest_publish_failure_rolls_back(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""A missing retry record must prevent the DB delete from committing."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "victim")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(tmp_path, {"victim"})
|
|
|
|
import api.profiles
|
|
from api.models import delete_cli_session
|
|
|
|
monkeypatch.setattr(
|
|
api.profiles, "get_active_hermes_home", lambda: str(tmp_path)
|
|
)
|
|
original_write_text = Path.write_text
|
|
|
|
def _deny_manifest_publish(path, *args, **kwargs):
|
|
if path.name.startswith(".cleanup_manifest_") and path.suffix == ".tmp":
|
|
raise PermissionError("manifest directory is read-only")
|
|
return original_write_text(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "write_text", _deny_manifest_publish)
|
|
assert delete_cli_session("victim") is False
|
|
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
assert conn.execute(
|
|
"SELECT 1 FROM sessions WHERE id = ?", ("victim",)
|
|
).fetchone() == (1,)
|
|
assert conn.execute(
|
|
"SELECT 1 FROM messages WHERE session_id = ?", ("victim",)
|
|
).fetchone() == (1,)
|
|
finally:
|
|
conn.close()
|
|
_assert_all_artifacts_exist(sessions_dir, {"victim"})
|
|
assert not list(sessions_dir.glob(".cleanup_manifest_*.json"))
|
|
assert not list(sessions_dir.glob(".cleanup_manifest_*.tmp"))
|
|
|
|
|
|
@pytest.mark.parametrize("payload", ['["orphan"', '{"orphan": true}', '[123]'])
|
|
def test_delete_cli_session_preserves_malformed_manifest_and_reports_failure(
|
|
tmp_path, monkeypatch, payload
|
|
):
|
|
"""Unknown pending IDs remain recoverable and make cleanup incomplete."""
|
|
state_db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, started_at TEXT,
|
|
source TEXT, parent_session_id TEXT, model_config TEXT,
|
|
end_reason TEXT, ended_at TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT, role TEXT, content TEXT, timestamp TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
_seed_session(conn, "victim")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
sessions_dir = _seed_transcript_artifacts(tmp_path, {"victim", "orphan"})
|
|
malformed = sessions_dir / ".cleanup_manifest_truncated.json"
|
|
malformed.write_text(payload, encoding="utf-8")
|
|
|
|
import api.profiles
|
|
from api.models import delete_cli_session
|
|
|
|
monkeypatch.setattr(
|
|
api.profiles, "get_active_hermes_home", lambda: str(tmp_path)
|
|
)
|
|
assert delete_cli_session("victim") is False
|
|
|
|
assert malformed.exists()
|
|
assert malformed.read_text(encoding="utf-8") == payload
|
|
_assert_all_artifacts_exist(sessions_dir, {"orphan"})
|
|
conn = sqlite3.connect(state_db)
|
|
try:
|
|
assert conn.execute(
|
|
"SELECT 1 FROM sessions WHERE id = ?", ("victim",)
|
|
).fetchone() is None
|
|
assert conn.execute(
|
|
"SELECT 1 FROM messages WHERE session_id = ?", ("victim",)
|
|
).fetchone() is None
|
|
finally:
|
|
conn.close()
|