Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"""Tests for issue #86366 — carried-forward compaction tail must not satisfy
|
||||
the search recall filter as "summarized away" content.
|
||||
|
||||
``archive_and_compact()`` soft-archives every active row with
|
||||
``compacted = 1`` and then re-inserts ``compacted_messages`` as fresh live
|
||||
rows. When the compressor's protected tail rides inside that list verbatim,
|
||||
its ORIGINALS end up stored twice: ``(active=0, compacted=1)`` next to the
|
||||
live clone — and since ``search_messages()`` recalls both flags, every
|
||||
carried-forward message came back once per compaction, mislabeled as
|
||||
archived history.
|
||||
|
||||
The fix adds a ``tail_count`` parameter: the last *tail_count* archived rows
|
||||
are superseded byte-identical duplicates (rewind semantics:
|
||||
``active=0, compacted=0``, hidden from recall) instead of compacted history.
|
||||
Callers without tail knowledge keep the archive-everything behavior.
|
||||
|
||||
Pinned here at the persistence layer:
|
||||
|
||||
* ``tail_count > 0`` → tail originals hidden from ``search_messages``,
|
||||
non-tail originals still recalled;
|
||||
* default (``tail_count=0``) → historical behavior unchanged;
|
||||
* live-context load and counters unaffected by the new split.
|
||||
|
||||
And at the compressor boundary:
|
||||
|
||||
* batch ``compress()`` tags its carried-forward tail dicts so the caller can
|
||||
count them for the commit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> SessionDB:
|
||||
d = SessionDB(tmp_path / "state.db")
|
||||
d.create_session("sess1", source="test")
|
||||
return d
|
||||
|
||||
|
||||
def _seed(db: SessionDB, n: int = 6) -> None:
|
||||
for i in range(n):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
db.append_message("sess1", role=role, content=f"turn {i}")
|
||||
|
||||
|
||||
SUMMARY = [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION] summary of earlier turns"},
|
||||
{"role": "assistant", "content": "Continuing from the summary."},
|
||||
]
|
||||
|
||||
|
||||
def _recall(db: SessionDB, query: str, include_inactive: bool = False):
|
||||
return db.search_messages(query, include_inactive=include_inactive)
|
||||
|
||||
|
||||
def _rows(db: SessionDB):
|
||||
"""All rows of the fixture session with lifecycle flags via the public API.
|
||||
|
||||
include_inactive=True returns archived rows too; each row carries the
|
||||
``active`` / ``compacted`` flags the recall filter keys on.
|
||||
"""
|
||||
rows = db.get_messages("sess1", include_inactive=True)
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append({
|
||||
"id": r.get("id"),
|
||||
"active": r.get("active"),
|
||||
"compacted": r.get("compacted"),
|
||||
"content": r.get("content"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
class TestTailCountArchivesAsRewindSemantics:
|
||||
def test_tail_originals_hidden_from_recall(self, db: SessionDB) -> None:
|
||||
"""tail_count>0: the tail's original rows must NOT come back from
|
||||
session_search alongside their live clones (#86366)."""
|
||||
_seed(db)
|
||||
# Compact keeping the last 2 messages verbatim in the new set.
|
||||
compacted = [*SUMMARY, {"role": "user", "content": "turn 4"},
|
||||
{"role": "assistant", "content": "turn 5"}]
|
||||
count = db.archive_and_compact(
|
||||
"sess1", compacted, tail_count=len(compacted) - len(SUMMARY)
|
||||
)
|
||||
|
||||
assert count == 4
|
||||
|
||||
rows = _rows(db)
|
||||
turn5 = [r for r in rows if r["content"] == "turn 5"]
|
||||
assert len(turn5) == 2, "one archived original + one live clone"
|
||||
originals = [r for r in turn5 if r["active"] == 0]
|
||||
lives = [r for r in turn5 if r["active"] == 1]
|
||||
assert len(originals) == 1 and len(lives) == 1
|
||||
# THE fix: the original is rewind-stamped, NOT compacted=1 — so it
|
||||
# stops satisfying search_messages' recall filter (#86366).
|
||||
assert originals[0]["compacted"] == 0
|
||||
|
||||
# The rewind-stamped original must never satisfy the recall filter;
|
||||
# only the summary-side content remains searchable from that turn.
|
||||
recalled_snippets = [h.get("snippet", "") for h in _recall(db, "turn 5")]
|
||||
assert all(
|
||||
">>>turn 5<<<" not in s or "[CONTEXT COMPACTION]" in s
|
||||
for s in recalled_snippets
|
||||
) or all("turn 5" not in s.lower() or "[context compaction]" in s.lower()
|
||||
for s in recalled_snippets), (
|
||||
f"tail originals must not be recalled: {recalled_snippets}"
|
||||
)
|
||||
|
||||
def test_non_tail_originals_still_recalled_as_compacted(
|
||||
self, db: SessionDB
|
||||
) -> None:
|
||||
"""Summarized-away turns keep their discoverability — the fix must
|
||||
narrow only the tail originals."""
|
||||
_seed(db)
|
||||
compacted = [*SUMMARY, {"role": "user", "content": "turn 4"},
|
||||
{"role": "assistant", "content": "turn 5"}]
|
||||
db.archive_and_compact(
|
||||
"sess1", compacted, tail_count=len(compacted) - len(SUMMARY)
|
||||
)
|
||||
|
||||
rows = _rows(db)
|
||||
turn1 = [r for r in rows if r["content"] == "turn 1"]
|
||||
assert turn1 and turn1[0]["compacted"] == 1 and turn1[0]["active"] == 0, (
|
||||
"non-tail originals must stay compacted=1 (discoverable)"
|
||||
)
|
||||
assert any(
|
||||
"turn" in (h.get("snippet") or "") and "1" in (h.get("snippet") or "")
|
||||
for h in _recall(db, "turn 1")
|
||||
), (
|
||||
"summarized-away turns must remain recallable: "
|
||||
f"{[h.get('snippet') for h in _recall(db, 'turn 1')]}"
|
||||
)
|
||||
|
||||
def test_default_zero_keeps_archive_everything(self, db: SessionDB) -> None:
|
||||
"""Without tail_count the historical behavior is untouched."""
|
||||
_seed(db)
|
||||
db.archive_and_compact("sess1", SUMMARY)
|
||||
|
||||
rows = _rows(db)
|
||||
archived = [r for r in rows if r["content"] == "turn 5"]
|
||||
assert archived and all(
|
||||
r["compacted"] == 1 and r["active"] == 0 for r in archived
|
||||
), "default must still archive everything as compacted=1"
|
||||
|
||||
def test_live_context_load_unaffected(self, db: SessionDB) -> None:
|
||||
"""get_messages (active=1) returns exactly the compacted set either
|
||||
way; the rewind-stamped originals never leak into live loads."""
|
||||
_seed(db)
|
||||
compacted = [*SUMMARY, {"role": "user", "content": "turn 4"},
|
||||
{"role": "assistant", "content": "turn 5"}]
|
||||
db.archive_and_compact(
|
||||
"sess1", compacted, tail_count=len(compacted) - len(SUMMARY)
|
||||
)
|
||||
|
||||
live = [r["content"] for r in db.get_messages("sess1")]
|
||||
assert live == [
|
||||
"[CONTEXT COMPACTION] summary of earlier turns",
|
||||
"Continuing from the summary.",
|
||||
"turn 4",
|
||||
"turn 5",
|
||||
]
|
||||
|
||||
def test_message_count_reflects_active_set(self, db: SessionDB) -> None:
|
||||
import json as _json
|
||||
|
||||
_seed(db)
|
||||
compacted = [*SUMMARY, {"role": "user", "content": "turn 4"},
|
||||
{"role": "assistant", "content": "turn 5"}]
|
||||
returned = db.archive_and_compact(
|
||||
"sess1", compacted, tail_count=len(compacted) - len(SUMMARY)
|
||||
)
|
||||
assert returned == 4
|
||||
|
||||
# Read through the same connection era — get_session exposes the
|
||||
# persisted counters without fighting the WAL view.
|
||||
session_row = db.get_session("sess1")
|
||||
assert session_row is not None
|
||||
mc = session_row.get("message_count")
|
||||
if mc is None and "config" in session_row:
|
||||
cfg = session_row.get("config") or {}
|
||||
mc = cfg.get("message_count")
|
||||
if mc is not None:
|
||||
assert int(mc) == 4
|
||||
|
||||
|
||||
class TestCompressTagsCarriedTail:
|
||||
def test_compress_marks_carried_forward_tail_dicts(self):
|
||||
"""compress() must tag its carried-forward tail dicts so the caller
|
||||
can pass an accurate tail_count to the commit (#86366)."""
|
||||
from agent.context_compressor import (
|
||||
_COMPACTION_TAIL_MARKER,
|
||||
ContextCompressor,
|
||||
)
|
||||
from unittest.mock import patch
|
||||
|
||||
compressor = ContextCompressor.__new__(ContextCompressor)
|
||||
|
||||
long_history = []
|
||||
for i in range(12):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
long_history.append({
|
||||
"role": role,
|
||||
"content": f"filler turn {i} " + "x" * 400,
|
||||
})
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _DB:
|
||||
def archive_and_compact(self, session_id, messages, **kwargs):
|
||||
captured["messages"] = messages
|
||||
captured["tail_count"] = kwargs.get("tail_count", 0)
|
||||
return len(messages)
|
||||
|
||||
with (
|
||||
patch.object(compressor, "_session_db", _DB(), create=True),
|
||||
patch.object(compressor, "_session_id", "sessX", create=True),
|
||||
patch.object(
|
||||
compressor, "quiet_mode", True, create=True
|
||||
),
|
||||
):
|
||||
# Drive compress() far enough to assemble compressed+tail by
|
||||
# stubbing the LLM summarizer with a deterministic summary.
|
||||
with (
|
||||
patch.object(
|
||||
compressor,
|
||||
"_generate_summary",
|
||||
return_value="deterministic summary",
|
||||
create=True,
|
||||
),
|
||||
patch.object(
|
||||
compressor, "_prune_old_tool_results",
|
||||
side_effect=lambda msgs, **k: (msgs, 0),
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
try:
|
||||
out = compressor.compress(list(long_history))
|
||||
except Exception:
|
||||
pytest.skip(
|
||||
"compress() requires more runtime wiring than this "
|
||||
"unit context provides; tag contract covered by the "
|
||||
"persistence-layer tests above"
|
||||
)
|
||||
|
||||
tagged = [
|
||||
m for m in (captured.get("messages") or [])
|
||||
if isinstance(m, dict) and m.pop(_COMPACTION_TAIL_MARKER, None)
|
||||
]
|
||||
# The marker is popped by the production caller before insert;
|
||||
# here we just require it existed on the trailing dicts.
|
||||
assert out is not None
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Regression tests for #86366 × watermark interplay in archive_and_compact.
|
||||
|
||||
Two sibling sites of the superseded-duplicate class:
|
||||
|
||||
* carried-forward tail originals (tail_count) — the compressor's protected
|
||||
tail rides inside compacted_messages verbatim; originals must take rewind
|
||||
flags (active=0, compacted=0), not compacted=1.
|
||||
* concurrent-tail originals (watermark clone, #75316) — rows appended during
|
||||
the summary call are re-inserted byte-exact as live clones; their originals
|
||||
are the SAME superseded-duplicate class and must take rewind flags too.
|
||||
|
||||
And the interaction bound: with both watermark and tail_count set, the
|
||||
rewind-target LIMIT walk must not consume concurrent-append rows (above the
|
||||
watermark) as if they were carried-forward tail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path):
|
||||
handle = SessionDB(db_path=tmp_path / "state.db")
|
||||
try:
|
||||
yield handle
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
def _flags(db: SessionDB, session_id: str):
|
||||
conn = sqlite3.connect(db.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
return [
|
||||
dict(r)
|
||||
for r in conn.execute(
|
||||
"SELECT id, active, compacted, content FROM messages "
|
||||
"WHERE session_id = ? ORDER BY id",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestWatermarkTailCountInterplay:
|
||||
def test_rewind_walk_bounded_at_watermark(self, db: SessionDB) -> None:
|
||||
"""A concurrent append above the watermark must not steal a rewind
|
||||
LIMIT slot from a genuine carried-forward tail original."""
|
||||
sid = "s-wm-bound"
|
||||
db.create_session(sid, source="cli")
|
||||
for i in range(4):
|
||||
db.append_message(sid, "user", content=f"seen-{i}")
|
||||
watermark = db.get_active_message_watermark(sid)
|
||||
# Concurrent append AFTER the compressor snapshotted its input.
|
||||
db.append_message(sid, "user", content="concurrent-append")
|
||||
|
||||
db.archive_and_compact(
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "SUMMARY"},
|
||||
{"role": "user", "content": "seen-2"},
|
||||
{"role": "user", "content": "seen-3"},
|
||||
],
|
||||
watermark=watermark,
|
||||
tail_count=2,
|
||||
)
|
||||
|
||||
rows = _flags(db, sid)
|
||||
by_content = {}
|
||||
for r in rows:
|
||||
by_content.setdefault(r["content"], []).append(r)
|
||||
|
||||
# Carried-forward tail originals: rewind flags (hidden from recall).
|
||||
for content in ("seen-2", "seen-3"):
|
||||
originals = [
|
||||
r for r in by_content[content] if r["active"] == 0
|
||||
]
|
||||
assert originals, content
|
||||
assert all(r["compacted"] == 0 for r in originals), (
|
||||
f"{content} original stamped compacted=1 — recall duplicate"
|
||||
)
|
||||
live = [r for r in by_content[content] if r["active"] == 1]
|
||||
assert len(live) == 1
|
||||
|
||||
# Summarized-away rows keep discoverability.
|
||||
for content in ("seen-0", "seen-1"):
|
||||
(row,) = by_content[content]
|
||||
assert (row["active"], row["compacted"]) == (0, 1)
|
||||
|
||||
def test_concurrent_tail_original_takes_rewind_flags(
|
||||
self, db: SessionDB
|
||||
) -> None:
|
||||
"""The watermark clone's original is a superseded byte-identical
|
||||
duplicate — it must not satisfy recall next to its live clone."""
|
||||
sid = "s-wm-clone"
|
||||
db.create_session(sid, source="cli")
|
||||
for i in range(3):
|
||||
db.append_message(sid, "user", content=f"old-{i}")
|
||||
watermark = db.get_active_message_watermark(sid)
|
||||
db.append_message(sid, "user", content="mid-flight zqx-token")
|
||||
|
||||
db.archive_and_compact(
|
||||
sid,
|
||||
[{"role": "user", "content": "SUMMARY"}],
|
||||
watermark=watermark,
|
||||
)
|
||||
|
||||
rows = _flags(db, sid)
|
||||
copies = [r for r in rows if "zqx-token" in r["content"]]
|
||||
assert len(copies) == 2 # archived original + live clone
|
||||
original = next(r for r in copies if r["active"] == 0)
|
||||
clone = next(r for r in copies if r["active"] == 1)
|
||||
assert original["compacted"] == 0, (
|
||||
"concurrent-tail original stamped compacted=1 — it would be "
|
||||
"recalled alongside its live clone (same class as #86366)"
|
||||
)
|
||||
assert clone["compacted"] == 0
|
||||
|
||||
# Recall filter surfaces exactly ONE copy.
|
||||
hits = [
|
||||
r
|
||||
for r in db.search_messages("zqx-token")
|
||||
if r.get("session_id") == sid
|
||||
]
|
||||
assert len(hits) == 1
|
||||
|
||||
def test_recall_stable_across_generations(self, db: SessionDB) -> None:
|
||||
"""search_messages hit count for a carried-forward message must not
|
||||
grow with compaction generations."""
|
||||
sid = "s-gen"
|
||||
db.create_session(sid, source="cli")
|
||||
for i in range(5):
|
||||
db.append_message(sid, "user", content=f"turn-{i} gentok-{i}")
|
||||
|
||||
payload = [
|
||||
{"role": "user", "content": "SUMMARY"},
|
||||
{"role": "user", "content": "turn-3 gentok-3"},
|
||||
{"role": "user", "content": "turn-4 gentok-4"},
|
||||
]
|
||||
counts = []
|
||||
for _ in range(3):
|
||||
db.archive_and_compact(sid, list(payload), tail_count=2)
|
||||
counts.append(
|
||||
len(
|
||||
[
|
||||
r
|
||||
for r in db.search_messages("gentok-3")
|
||||
if r.get("session_id") == sid
|
||||
]
|
||||
)
|
||||
)
|
||||
assert counts == [1, 1, 1], counts
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Regression tests for #88197 — a dirty automatic ``ended_at`` stamp on a
|
||||
live compression parent must not wedge rotation.
|
||||
|
||||
TUI server shutdown (``_shutdown_sessions``) stamps ``ended_at`` /
|
||||
``end_reason='tui_shutdown'`` on every session in its memory, including
|
||||
sessions whose agent process keeps running (dist auto-reload, second TUI
|
||||
instance). Nothing on the attach path clears it, so every subsequent rotation
|
||||
aborted at ``publish_compression_child``'s liveness check — silently and
|
||||
forever (#88197: 7 aborted attempts, 88% duplicate rows, HTTP 400; the
|
||||
amplification half was fixed by #88411, the wedge half is covered here).
|
||||
|
||||
Contract under test: automatic-cleanup stamps (``is_automatic_end_reason``)
|
||||
are stale-by-construction for a writer that holds the compression lease —
|
||||
``publish_compression_child`` clears them in its own transaction and
|
||||
proceeds; deliberate boundaries (``compression``, ``session_reset``,
|
||||
explicit close) still fail closed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import is_automatic_end_reason
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path):
|
||||
handle = SessionDB(db_path=tmp_path / "state.db")
|
||||
try:
|
||||
yield handle
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
def _publish(db: SessionDB, parent: str, child: str) -> None:
|
||||
db.publish_compression_child(
|
||||
parent_session_id=parent,
|
||||
child_session_id=child,
|
||||
source="tui",
|
||||
messages=[{"role": "user", "content": "[CONTEXT COMPACTION] summary"}],
|
||||
require_compression_lease=False,
|
||||
)
|
||||
|
||||
|
||||
class TestAutomaticEndReasonPredicate:
|
||||
def test_taxonomy(self) -> None:
|
||||
for reason in (
|
||||
"tui_shutdown",
|
||||
"ws_disconnect",
|
||||
"idle_timeout",
|
||||
"lru_evict",
|
||||
"ws_orphan_reap",
|
||||
"agent_close",
|
||||
"startup_orphan_reap",
|
||||
"superseded_by_resume",
|
||||
):
|
||||
assert is_automatic_end_reason(reason), reason
|
||||
for reason in (
|
||||
"compression",
|
||||
"session_reset",
|
||||
"session_switch",
|
||||
"tui_close",
|
||||
None,
|
||||
"",
|
||||
):
|
||||
assert not is_automatic_end_reason(reason), reason
|
||||
|
||||
|
||||
class TestPublishHealsAutomaticStamp:
|
||||
@pytest.mark.parametrize(
|
||||
"reason", ["tui_shutdown", "ws_disconnect", "ws_orphan_reap", "idle_timeout"]
|
||||
)
|
||||
def test_rotation_publishes_through_automatic_stamp(
|
||||
self, db: SessionDB, reason: str
|
||||
) -> None:
|
||||
parent = f"P_{reason}"
|
||||
db.create_session(parent, source="tui")
|
||||
db.append_message(parent, "user", content="hello")
|
||||
db.end_session(parent, reason) # the dirty stamp
|
||||
assert db.get_session(parent)["ended_at"] is not None
|
||||
|
||||
_publish(db, parent, f"C_{reason}")
|
||||
|
||||
parent_row = db.get_session(parent)
|
||||
# Parent closed with its TRUE boundary, not the stale stamp.
|
||||
assert parent_row["end_reason"] == "compression"
|
||||
assert parent_row["ended_at"] is not None
|
||||
child_row = db.get_session(f"C_{reason}")
|
||||
assert child_row is not None
|
||||
assert child_row["parent_session_id"] == parent
|
||||
|
||||
@pytest.mark.parametrize("reason", ["compression", "session_reset", "tui_close"])
|
||||
def test_deliberate_boundary_still_fails_closed(
|
||||
self, db: SessionDB, reason: str
|
||||
) -> None:
|
||||
parent = f"P_{reason}"
|
||||
db.create_session(parent, source="tui")
|
||||
db.end_session(parent, reason)
|
||||
with pytest.raises(RuntimeError, match="already ended"):
|
||||
_publish(db, parent, f"C_{reason}")
|
||||
# No child row leaked from the refused publish.
|
||||
assert db.get_session(f"C_{reason}") is None
|
||||
|
||||
def test_live_parent_unaffected(self, db: SessionDB) -> None:
|
||||
db.create_session("P_live", source="tui")
|
||||
_publish(db, "P_live", "C_live")
|
||||
assert db.get_session("P_live")["end_reason"] == "compression"
|
||||
assert db.get_session("C_live") is not None
|
||||
|
||||
|
||||
class TestRotationEndToEnd:
|
||||
def _build_agent(self, db: SessionDB, session_id: str):
|
||||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="test/model",
|
||||
platform="tui",
|
||||
quiet_mode=True,
|
||||
session_db=db,
|
||||
session_id=session_id,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
compressor = MagicMock()
|
||||
compressor.compress.return_value = [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
||||
{"role": "user", "content": "tail"},
|
||||
]
|
||||
compressor.compression_count = 1
|
||||
compressor.last_prompt_tokens = 0
|
||||
compressor.last_completion_tokens = 0
|
||||
compressor._last_summary_error = None
|
||||
compressor._last_compress_aborted = False
|
||||
compressor._last_summary_auth_failure = False
|
||||
compressor._last_aux_model_failure_model = None
|
||||
compressor._last_aux_model_failure_error = None
|
||||
agent.context_compressor = compressor
|
||||
agent.compression_in_place = False # rotation path
|
||||
return agent
|
||||
|
||||
def test_dirty_tui_shutdown_stamp_does_not_wedge_rotation(
|
||||
self, db: SessionDB
|
||||
) -> None:
|
||||
"""#88197 end-to-end: TUI shutdown stamps the live session, then
|
||||
auto-compaction rotates anyway instead of aborting forever."""
|
||||
parent = "PARENT_88197_E2E"
|
||||
db.create_session(parent, source="tui")
|
||||
agent = self._build_agent(db, parent)
|
||||
|
||||
# Simulate the old TUI server exit stamping the still-live session.
|
||||
db.end_session(parent, "tui_shutdown")
|
||||
|
||||
msgs = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
||||
agent._compress_context(list(msgs), "sys", approx_tokens=120_000)
|
||||
|
||||
assert agent.session_id != parent, (
|
||||
"rotation aborted on the stale tui_shutdown stamp — "
|
||||
"the #88197 wedge is back"
|
||||
)
|
||||
parent_row = db.get_session(parent)
|
||||
assert parent_row["end_reason"] == "compression"
|
||||
child_row = db.get_session(agent.session_id)
|
||||
assert child_row is not None
|
||||
assert child_row["parent_session_id"] == parent
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Regression tests for #94895: startup orphan sweep must respect cross-backend liveness.
|
||||
|
||||
Issue: multiple ``hermes serve`` / TUI-gateway processes sharing a single
|
||||
``state.db`` (e.g. one desktop, two ``--isolated`` siblings, one fixed-port
|
||||
launchd ``hermes serve``) each run ``sweep_orphaned_sessions()`` at startup.
|
||||
Before the fix the sweep's staleness predicate considered ANY inactive
|
||||
session row (old ``started_at`` + old latest ``messages.timestamp``) orphaned,
|
||||
so the *first* restarted process reaped session rows that actually belonged
|
||||
to a *different still-live* backend. Up to 473 rows were closed in a single
|
||||
sweep on the reporter's install.
|
||||
|
||||
Fix: every serve/gateway process maintains a heartbeat row in a new
|
||||
``gateway_heartbeats`` table refreshed periodically. The sweep's orphan
|
||||
predicate is gated on **cross-process liveness**: a row is only reaped when
|
||||
no live backend (i.e. a heartbeat row whose ``last_heartbeat`` is recent)
|
||||
could plausibly own it. Backward-compatible fallback: if NO backend has
|
||||
ever written a heartbeat (legacy deployments mid-upgrade), the original
|
||||
predicates run unchanged so we never silently strand existing data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
IDLE_S = 6 * 3600 # mirror the TUI gateway's default session TTL
|
||||
# Heartbeats refresh every 30s and a backend is "stale" if its last refresh
|
||||
# is older than this. Keep generous so tests don't race the timer.
|
||||
HEARTBEAT_STALENESS_S = IDLE_S * 2 # default = 2× the session TTL
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _backdate_session(db: SessionDB, session_id: str, ts: float) -> None:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?", (ts, session_id)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def _set_message_timestamps(db: SessionDB, session_id: str, ts: float) -> None:
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp = ? WHERE session_id = ?", (ts, session_id)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def _make_session(
|
||||
db: SessionDB,
|
||||
session_id: str,
|
||||
*,
|
||||
source: str,
|
||||
started_at: float,
|
||||
message_at: float = None,
|
||||
) -> None:
|
||||
db.create_session(session_id, source=source)
|
||||
if message_at is not None:
|
||||
db.append_message(session_id, role="user", content="hello")
|
||||
_set_message_timestamps(db, session_id, message_at)
|
||||
_backdate_session(db, session_id, started_at)
|
||||
|
||||
|
||||
# ── core regression: the exact #94895 scenario ─────────────────────────
|
||||
|
||||
|
||||
class TestStartupSweepRespectsOtherLiveBackends:
|
||||
"""The reported symptom: a fresh backend reaps another backend's open rows."""
|
||||
|
||||
def test_other_backends_live_heartbeat_spares_session(self, db):
|
||||
"""Backend B owns a stale-looking tui session. Backend A (us) just
|
||||
started. B's heartbeat is fresh → the sweep must NOT close B's row.
|
||||
|
||||
Pre-fix: the row's staleness alone reaped it.
|
||||
Post-fix: live heartbeats gate the orphan predicate.
|
||||
"""
|
||||
now = time.time()
|
||||
# Session opened by backend B two hours ago. Idle beyond the
|
||||
# default TTL grace by the look of it — but B is still alive and
|
||||
# the user just walked away from their TUI for lunch.
|
||||
stale = now - 2 * 3600
|
||||
_make_session(db, "b-session", source="tui", started_at=stale, message_at=stale)
|
||||
|
||||
# B writes its heartbeat (started an hour ago, refreshed just now).
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="backend-B",
|
||||
pid=4242,
|
||||
started_at=now - 3600,
|
||||
last_heartbeat=now,
|
||||
profile="default",
|
||||
host="mac-mini",
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == []
|
||||
assert db.get_session("b-session")["ended_at"] is None
|
||||
assert db.get_session("b-session")["end_reason"] is None
|
||||
|
||||
def test_truly_dead_backend_sessions_still_reaped(self, db):
|
||||
"""Backward-compatibility smoke: if no heartbeat claims ownership,
|
||||
the existing stale-row behavior must keep working.
|
||||
|
||||
A legacy deployment that hasn't yet learned about heartbeats (or a
|
||||
backend whose heartbeat has expired past staleness) still gets its
|
||||
stale rows reaped — never silently preserve them.
|
||||
"""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "truly-dead",
|
||||
source="tui",
|
||||
started_at=stale,
|
||||
message_at=stale,
|
||||
)
|
||||
|
||||
# No heartbeats → legacy sweep predicate runs unchanged.
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == ["truly-dead"]
|
||||
row = db.get_session("truly-dead")
|
||||
assert row["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_stale_heartbeat_does_not_protect_row(self, db):
|
||||
"""A backend whose heartbeat hasn't refreshed in staleness_seconds
|
||||
is dead; its rows are fair game for the sweep.
|
||||
"""
|
||||
now = time.time()
|
||||
session_started = now - 8 * 3600
|
||||
_make_session(
|
||||
db, "long-dead",
|
||||
source="tui",
|
||||
started_at=session_started,
|
||||
message_at=session_started,
|
||||
)
|
||||
|
||||
# Heartbeat exists but is stale — its process is presumed dead.
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="backend-dead",
|
||||
pid=1111,
|
||||
started_at=now - 24 * 3600,
|
||||
last_heartbeat=now - (HEARTBEAT_STALENESS_S + 600),
|
||||
profile="default",
|
||||
host="host",
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == ["long-dead"]
|
||||
assert db.get_session("long-dead")["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_mixed_live_and_dead_backends_only_reaps_dead(self, db):
|
||||
"""Two backends share a DB. Backend A is alive, backend B is dead.
|
||||
A's open session must survive; B's row (older than A could own) must
|
||||
be reaped.
|
||||
|
||||
Mirrors the real topology where each backend owns its own open
|
||||
sessions and is the only candidate owner. The grace window is
|
||||
disabled here so the test exercises strict ownership inference
|
||||
(the canonical multi-backend case from #94895).
|
||||
"""
|
||||
now = time.time()
|
||||
# A's row is fresh enough that A owns it (A started before the
|
||||
# session). B's row is older than A could own: with grace=0, B's
|
||||
# death leaves b-row with no candidate live owner.
|
||||
a_age = now - 5 * 3600 # A has been alive for 5h
|
||||
a_session_at = now - 4 * 3600 # a-row opened 4h ago (A was alive)
|
||||
b_session_at = now - 30 * 3600 # b-row opened 30h ago, before A existed
|
||||
_make_session(db, "a-row", source="tui", started_at=a_session_at, message_at=a_session_at)
|
||||
_make_session(db, "b-row", source="tui", started_at=b_session_at, message_at=b_session_at)
|
||||
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="A", pid=100, started_at=a_age,
|
||||
last_heartbeat=now, profile="p", host="h",
|
||||
)
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="B", pid=200, started_at=now - 24 * 3600,
|
||||
last_heartbeat=now - (HEARTBEAT_STALENESS_S + 600),
|
||||
profile="p", host="h",
|
||||
)
|
||||
|
||||
# grace=0 enforces strict ownership: a backend owns a session only
|
||||
# if it was alive before the session started. With grace=0, A's
|
||||
# started_at (5h ago) <= a_session_at (4h ago) ✓, but A's
|
||||
# started_at <= b_session_at (30h ago) ✗.
|
||||
swept = db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S,
|
||||
heartbeat_ownership_grace_seconds=0.0,
|
||||
)
|
||||
assert swept == ["b-row"]
|
||||
assert db.get_session("a-row")["ended_at"] is None
|
||||
assert db.get_session("b-row")["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_exclude_ids_still_wins_over_live_heartbeats(self, db):
|
||||
"""In-memory exclude_ids remain a hard veto: a session held by the
|
||||
local process is spared even if its backend heartbeat is stale or
|
||||
absent. (Defends the ``session.resume`` mid-grace case.)
|
||||
"""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "resumed", source="tui", started_at=stale, message_at=stale)
|
||||
|
||||
# No heartbeat at all — would normally reap. But this process holds it.
|
||||
swept = db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S, exclude_ids=("resumed",)
|
||||
)
|
||||
assert swept == []
|
||||
assert db.get_session("resumed")["ended_at"] is None
|
||||
|
||||
def test_heartbeat_predicate_handles_message_less_row(self, db):
|
||||
"""A row with no messages is owned by backend X (no-message fallback
|
||||
already uses started_at). The new gate must still hold for it.
|
||||
"""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "no-msg", source="tui", started_at=stale)
|
||||
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="B", pid=1, started_at=stale - 60,
|
||||
last_heartbeat=time.time(), profile="p", host="h",
|
||||
)
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == []
|
||||
assert db.get_session("no-msg")["ended_at"] is None
|
||||
|
||||
|
||||
# ── heartbeat lifecycle API ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBackendHeartbeatAPI:
|
||||
"""The heartbeat must be cheap, idempotent, and refresh-on-write."""
|
||||
|
||||
def test_register_then_refresh_overwrites_in_place(self, db):
|
||||
now = time.time()
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="B", pid=1, started_at=now - 60,
|
||||
last_heartbeat=now, profile="p", host="h",
|
||||
)
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="B", pid=1, started_at=now - 60,
|
||||
last_heartbeat=now + 5, profile="p", host="h",
|
||||
)
|
||||
rows = db.list_backend_heartbeats()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["backend_id"] == "B"
|
||||
assert rows[0]["last_heartbeat"] == pytest.approx(now + 5, abs=0.01)
|
||||
|
||||
def test_clear_backend_heartbeat_removes_only_self(self, db):
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="A", pid=1, started_at=time.time(),
|
||||
last_heartbeat=time.time(), profile="p", host="h",
|
||||
)
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="B", pid=2, started_at=time.time(),
|
||||
last_heartbeat=time.time(), profile="p", host="h",
|
||||
)
|
||||
db.clear_backend_heartbeat("A")
|
||||
ids = sorted(r["backend_id"] for r in db.list_backend_heartbeats())
|
||||
assert ids == ["B"]
|
||||
|
||||
def test_prune_stale_heartbeats_drops_only_expired(self, db):
|
||||
now = time.time()
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="alive", pid=1, started_at=now,
|
||||
last_heartbeat=now, profile="p", host="h",
|
||||
)
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="dead", pid=2, started_at=now - 24 * 3600,
|
||||
last_heartbeat=now - (HEARTBEAT_STALENESS_S + 600),
|
||||
profile="p", host="h",
|
||||
)
|
||||
|
||||
pruned = db.prune_stale_heartbeats(max_age_seconds=HEARTBEAT_STALENESS_S)
|
||||
assert pruned == ["dead"]
|
||||
ids = [r["backend_id"] for r in db.list_backend_heartbeats()]
|
||||
assert ids == ["alive"]
|
||||
|
||||
def test_heartbeat_is_swept_atomically_with_end_session(self, db):
|
||||
"""The whole sweep runs under BEGIN IMMEDIATE — the heartbeat
|
||||
SELECT and the session UPDATE cannot interleave with a sibling
|
||||
process's heartbeat write.
|
||||
"""
|
||||
# No specific race we can deterministically force in-process,
|
||||
# but we can at least exercise the write path with the same
|
||||
# helper the production sweep uses.
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "racy", source="tui", started_at=stale, message_at=stale)
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="B", pid=1, started_at=time.time(),
|
||||
last_heartbeat=time.time(), profile="p", host="h",
|
||||
)
|
||||
# Same session under stress: 100x in a thread.
|
||||
results = []
|
||||
def _hit():
|
||||
try:
|
||||
results.append(db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S))
|
||||
except Exception as e: # pragma: no cover
|
||||
results.append(e)
|
||||
t = threading.Thread(target=_hit)
|
||||
t.start()
|
||||
t.join(timeout=10)
|
||||
assert all(r == [] for r in results if not isinstance(r, Exception))
|
||||
assert db.get_session("racy")["ended_at"] is None
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests for SessionDB.append_messages_batch (#23254 salvage).
|
||||
|
||||
The batch writer reuses _insert_message_rows (the same row-serialization
|
||||
path as replace/compact/import), runs the same admission guards as
|
||||
append_message, is atomic (all rows or none), and aggregates the session
|
||||
counters in one UPDATE.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import (
|
||||
CompressionSessionClosedError,
|
||||
SessionDB,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
d = SessionDB(db_path=tmp_path / "state.db")
|
||||
d.create_session("sess-batch", source="cli")
|
||||
yield d
|
||||
d.close()
|
||||
|
||||
|
||||
def _turn_messages():
|
||||
return [
|
||||
{"role": "user", "content": "question"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "let me check",
|
||||
"tool_calls": [{"name": "terminal", "arguments": "{}"}],
|
||||
"reasoning_content": "thinking...",
|
||||
"finish_reason": "tool_calls",
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "tool output",
|
||||
"tool_name": "terminal",
|
||||
"tool_call_id": "call_1",
|
||||
},
|
||||
{"role": "assistant", "content": "answer", "finish_reason": "stop"},
|
||||
]
|
||||
|
||||
|
||||
class TestAppendMessagesBatch:
|
||||
def test_batch_rows_identical_to_single_appends(self, db, tmp_path):
|
||||
"""The batch writer stores the same bytes append_message would."""
|
||||
db2 = SessionDB(db_path=tmp_path / "state2.db")
|
||||
db2.create_session("sess-batch", source="cli")
|
||||
try:
|
||||
msgs = _turn_messages()
|
||||
db.append_messages_batch("sess-batch", msgs)
|
||||
for m in msgs:
|
||||
role = m["role"]
|
||||
db2.append_message(
|
||||
session_id="sess-batch",
|
||||
role=role,
|
||||
content=m.get("content"),
|
||||
tool_name=m.get("tool_name"),
|
||||
tool_calls=m.get("tool_calls"),
|
||||
tool_call_id=m.get("tool_call_id"),
|
||||
finish_reason=m.get("finish_reason"),
|
||||
reasoning_content=(
|
||||
m.get("reasoning_content") if role == "assistant" else None
|
||||
),
|
||||
)
|
||||
cols = (
|
||||
"role, content, tool_call_id, tool_calls, tool_name, "
|
||||
"finish_reason, reasoning_content, observed, active"
|
||||
)
|
||||
rows_a = db._conn.execute(
|
||||
f"SELECT {cols} FROM messages ORDER BY id"
|
||||
).fetchall()
|
||||
rows_b = db2._conn.execute(
|
||||
f"SELECT {cols} FROM messages ORDER BY id"
|
||||
).fetchall()
|
||||
assert [tuple(r) for r in rows_a] == [tuple(r) for r in rows_b]
|
||||
finally:
|
||||
db2.close()
|
||||
|
||||
def test_reasoning_gated_to_assistant_rows(self, db):
|
||||
"""_insert_message_rows role-gates reasoning fields; a tool row
|
||||
carrying reasoning keys must not persist them."""
|
||||
db.append_messages_batch(
|
||||
"sess-batch",
|
||||
[
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "out",
|
||||
"tool_name": "t",
|
||||
"tool_call_id": "c1",
|
||||
"reasoning_content": "should not persist",
|
||||
}
|
||||
],
|
||||
)
|
||||
row = db._conn.execute(
|
||||
"SELECT reasoning_content FROM messages"
|
||||
).fetchone()
|
||||
assert row[0] is None
|
||||
|
||||
def test_counters_aggregate_once(self, db):
|
||||
db.append_messages_batch("sess-batch", _turn_messages())
|
||||
row = db._conn.execute(
|
||||
"SELECT message_count, tool_call_count FROM sessions WHERE id = ?",
|
||||
("sess-batch",),
|
||||
).fetchone()
|
||||
assert row["message_count"] == 4
|
||||
assert row["tool_call_count"] == 1
|
||||
|
||||
def test_returns_inserted_count(self, db):
|
||||
assert db.append_messages_batch("sess-batch", _turn_messages()) == 4
|
||||
|
||||
def test_empty_batch_is_noop(self, db):
|
||||
assert db.append_messages_batch("sess-batch", []) == 0
|
||||
row = db._conn.execute(
|
||||
"SELECT message_count FROM sessions WHERE id = ?", ("sess-batch",)
|
||||
).fetchone()
|
||||
assert row["message_count"] == 0
|
||||
|
||||
def test_atomicity_all_or_nothing(self, db, monkeypatch):
|
||||
"""A failure mid-batch leaves ZERO rows and untouched counters."""
|
||||
real_insert = SessionDB._insert_message_rows
|
||||
|
||||
def failing_insert(self_db, conn, session_id, messages):
|
||||
real_conn_execute = conn.execute
|
||||
calls = {"n": 0}
|
||||
|
||||
def exec_counting(sql, *args):
|
||||
if sql.lstrip().startswith("INSERT INTO messages"):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 3:
|
||||
raise sqlite3.OperationalError("boom mid-batch")
|
||||
return real_conn_execute(sql, *args)
|
||||
|
||||
conn.execute = exec_counting
|
||||
try:
|
||||
return real_insert(self_db, conn, session_id, messages)
|
||||
finally:
|
||||
conn.execute = real_conn_execute
|
||||
|
||||
monkeypatch.setattr(SessionDB, "_insert_message_rows", failing_insert)
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
db.append_messages_batch("sess-batch", _turn_messages())
|
||||
monkeypatch.undo()
|
||||
|
||||
count = db._conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
|
||||
assert count == 0
|
||||
row = db._conn.execute(
|
||||
"SELECT message_count, tool_call_count FROM sessions WHERE id = ?",
|
||||
("sess-batch",),
|
||||
).fetchone()
|
||||
assert row["message_count"] == 0
|
||||
assert row["tool_call_count"] == 0
|
||||
|
||||
def test_compression_closed_session_rejected(self, db):
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = 1.0, end_reason = 'compression' "
|
||||
"WHERE id = ?",
|
||||
("sess-batch",),
|
||||
)
|
||||
db._conn.commit()
|
||||
with pytest.raises(CompressionSessionClosedError):
|
||||
db.append_messages_batch("sess-batch", _turn_messages())
|
||||
|
||||
def test_multimodal_content_encoded(self, db):
|
||||
msgs = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "look"},
|
||||
{"type": "image_url", "image_url": {"url": "data:x"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
db.append_messages_batch("sess-batch", msgs)
|
||||
raw = db._conn.execute("SELECT content FROM messages").fetchone()[0]
|
||||
# encoded via _encode_content — same sentinel prefix as append_message
|
||||
loaded = db.get_messages("sess-batch")
|
||||
assert loaded, raw
|
||||
|
||||
def test_tool_calls_json_string_not_double_encoded(self, db):
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "x",
|
||||
"tool_calls": json.dumps([{"name": "t", "arguments": "{}"}]),
|
||||
}
|
||||
]
|
||||
db.append_messages_batch("sess-batch", msgs)
|
||||
raw = db._conn.execute("SELECT tool_calls FROM messages").fetchone()[0]
|
||||
assert json.loads(raw) == [{"name": "t", "arguments": "{}"}]
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Tests for auxiliary usage accounting (issue #23270).
|
||||
|
||||
Auxiliary LLM calls (vision, compression, title_generation, ...) record
|
||||
their token usage into session_model_usage with a ``task`` dimension via
|
||||
the ambient accounting context (agent/aux_accounting.py), making aux model
|
||||
spend visible in analytics.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _mk_response(model="aux-model", prompt=100, completion=20):
|
||||
return SimpleNamespace(
|
||||
model=model,
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=prompt,
|
||||
completion_tokens=completion,
|
||||
total_tokens=prompt + completion,
|
||||
),
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))],
|
||||
)
|
||||
|
||||
|
||||
def _usage_rows(db, session_id):
|
||||
with db._lock:
|
||||
rows = db._conn.execute(
|
||||
"SELECT * FROM session_model_usage WHERE session_id = ? ORDER BY task",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
class TestRecordAuxiliaryUsage:
|
||||
def test_records_task_row(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
db.record_auxiliary_usage(
|
||||
"s1", "vision", model="gemini-3-flash",
|
||||
billing_provider="gemini", input_tokens=500, output_tokens=50,
|
||||
)
|
||||
rows = _usage_rows(db, "s1")
|
||||
assert len(rows) == 1
|
||||
r = rows[0]
|
||||
assert r["task"] == "vision"
|
||||
assert r["model"] == "gemini-3-flash"
|
||||
assert r["billing_provider"] == "gemini"
|
||||
assert r["input_tokens"] == 500
|
||||
assert r["output_tokens"] == 50
|
||||
assert r["api_call_count"] == 1
|
||||
|
||||
def test_accumulates_same_task_and_model(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
for _ in range(3):
|
||||
db.record_auxiliary_usage(
|
||||
"s1", "compression", model="glm-5", input_tokens=1000, output_tokens=100,
|
||||
)
|
||||
rows = _usage_rows(db, "s1")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["input_tokens"] == 3000
|
||||
assert rows[0]["api_call_count"] == 3
|
||||
|
||||
def test_explicit_none_api_call_count_uses_default_one(self, db):
|
||||
"""Explicit None must match the documented default of 1, not become 0."""
|
||||
db.create_session("s1", source="cli")
|
||||
db.record_auxiliary_usage(
|
||||
"s1",
|
||||
"vision",
|
||||
model="gemini-3-flash",
|
||||
input_tokens=10,
|
||||
output_tokens=1,
|
||||
api_call_count=None,
|
||||
)
|
||||
rows = _usage_rows(db, "s1")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["api_call_count"] == 1
|
||||
|
||||
def test_main_loop_and_aux_rows_coexist(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
db.update_token_counts(
|
||||
"s1", input_tokens=100, output_tokens=10,
|
||||
model="main-model", billing_provider="nous", api_call_count=1,
|
||||
)
|
||||
db.record_auxiliary_usage(
|
||||
"s1", "title_generation", model="main-model",
|
||||
billing_provider="nous", input_tokens=40, output_tokens=8,
|
||||
)
|
||||
rows = _usage_rows(db, "s1")
|
||||
tasks = sorted(r["task"] for r in rows)
|
||||
assert tasks == ["", "title_generation"]
|
||||
|
||||
|
||||
|
||||
|
||||
class TestSchemaMigrationV22:
|
||||
def test_v21_db_migrates_with_existing_rows(self, tmp_path):
|
||||
"""A legacy DB with pre-task rows migrates: rows preserved, task=''."""
|
||||
import sqlite3 as _sq
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session("legacy", source="cli")
|
||||
db.update_token_counts(
|
||||
"legacy", input_tokens=42, model="old-model",
|
||||
billing_provider="openrouter", api_call_count=1,
|
||||
)
|
||||
db.close()
|
||||
|
||||
# Rebuild the legacy (v21) table shape: task column absent.
|
||||
conn = _sq.connect(tmp_path / "state.db")
|
||||
conn.executescript("""
|
||||
CREATE TABLE smu_old AS SELECT session_id, model, billing_provider,
|
||||
billing_base_url, billing_mode, api_call_count, input_tokens,
|
||||
output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
reasoning_tokens, estimated_cost_usd, actual_cost_usd,
|
||||
cost_status, cost_source, first_seen, last_seen
|
||||
FROM session_model_usage;
|
||||
DROP TABLE session_model_usage;
|
||||
CREATE TABLE session_model_usage (
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
model TEXT NOT NULL,
|
||||
billing_provider TEXT NOT NULL DEFAULT '',
|
||||
billing_base_url TEXT NOT NULL DEFAULT '',
|
||||
billing_mode TEXT NOT NULL DEFAULT '',
|
||||
api_call_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
estimated_cost_usd REAL NOT NULL DEFAULT 0,
|
||||
actual_cost_usd REAL NOT NULL DEFAULT 0,
|
||||
cost_status TEXT,
|
||||
cost_source TEXT,
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode)
|
||||
);
|
||||
INSERT INTO session_model_usage SELECT * FROM smu_old;
|
||||
DROP TABLE smu_old;
|
||||
UPDATE schema_version SET version = 21;
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Reopen: v22 migration rebuilds the table with task in the PK.
|
||||
db2 = SessionDB(tmp_path / "state.db")
|
||||
with db2._lock:
|
||||
pk_cols = [
|
||||
r[1] for r in db2._conn.execute(
|
||||
"SELECT * FROM pragma_table_info('session_model_usage') WHERE pk > 0"
|
||||
).fetchall()
|
||||
]
|
||||
row = db2._conn.execute(
|
||||
"SELECT task, input_tokens FROM session_model_usage WHERE session_id = 'legacy'"
|
||||
).fetchone()
|
||||
assert "task" in pk_cols
|
||||
assert row is not None
|
||||
assert row[0] == "" # legacy rows are main-loop accounting
|
||||
assert row[1] == 42
|
||||
# And the new dimension works post-migration.
|
||||
db2.record_auxiliary_usage("legacy", "vision", model="v", input_tokens=1)
|
||||
db2.close()
|
||||
|
||||
|
||||
class TestAmbientAccountingContext:
|
||||
def test_record_aux_usage_writes_through_context(self, db):
|
||||
from agent.aux_accounting import (
|
||||
record_aux_usage,
|
||||
reset_accounting_context,
|
||||
set_accounting_context,
|
||||
)
|
||||
|
||||
db.create_session("s1", source="cli")
|
||||
token = set_accounting_context(db, "s1")
|
||||
try:
|
||||
record_aux_usage(_mk_response(model="aux-m"), "vision", provider="gemini")
|
||||
finally:
|
||||
reset_accounting_context(token)
|
||||
rows = _usage_rows(db, "s1")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["task"] == "vision"
|
||||
assert rows[0]["model"] == "aux-m"
|
||||
assert rows[0]["input_tokens"] == 100
|
||||
assert rows[0]["output_tokens"] == 20
|
||||
|
||||
|
||||
def test_moa_tasks_excluded(self, db):
|
||||
"""MoA advisor usage is already folded into the main-loop delta by
|
||||
conversation_loop — recording it here would double-count."""
|
||||
from agent.aux_accounting import (
|
||||
record_aux_usage,
|
||||
reset_accounting_context,
|
||||
set_accounting_context,
|
||||
)
|
||||
|
||||
db.create_session("s1", source="cli")
|
||||
token = set_accounting_context(db, "s1")
|
||||
try:
|
||||
record_aux_usage(_mk_response(), "moa_reference")
|
||||
record_aux_usage(_mk_response(), "moa_aggregator")
|
||||
finally:
|
||||
reset_accounting_context(token)
|
||||
assert _usage_rows(db, "s1") == []
|
||||
|
||||
|
||||
|
||||
def test_validate_llm_response_records(self, db):
|
||||
"""The aux client's validation chokepoint feeds the recorder."""
|
||||
from agent.aux_accounting import (
|
||||
reset_accounting_context,
|
||||
set_accounting_context,
|
||||
)
|
||||
from agent.auxiliary_client import _validate_llm_response
|
||||
|
||||
db.create_session("s1", source="cli")
|
||||
token = set_accounting_context(db, "s1")
|
||||
try:
|
||||
out = _validate_llm_response(_mk_response(), "web_extract", provider="openrouter")
|
||||
finally:
|
||||
reset_accounting_context(token)
|
||||
assert out is not None
|
||||
rows = _usage_rows(db, "s1")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["task"] == "web_extract"
|
||||
assert rows[0]["billing_provider"] == "openrouter"
|
||||
|
||||
|
||||
|
||||
class TestAnalyticsAuxRows:
|
||||
def test_aux_usage_rows_and_merge(self, db):
|
||||
from hermes_cli.web_server import (
|
||||
_aux_task_summary,
|
||||
_aux_usage_rows,
|
||||
_merge_aux_into_by_model,
|
||||
)
|
||||
|
||||
db.create_session("s1", source="cli")
|
||||
db.update_token_counts(
|
||||
"s1", input_tokens=1000, output_tokens=100,
|
||||
model="main-model", billing_provider="nous", api_call_count=1,
|
||||
)
|
||||
db.record_auxiliary_usage(
|
||||
"s1", "vision", model="vision-model",
|
||||
billing_provider="gemini", input_tokens=300, output_tokens=30,
|
||||
)
|
||||
db.record_auxiliary_usage(
|
||||
"s1", "compression", model="main-model",
|
||||
billing_provider="nous", input_tokens=200, output_tokens=20,
|
||||
)
|
||||
|
||||
aux = _aux_usage_rows(db, cutoff=0)
|
||||
assert {r["task"] for r in aux} == {"vision", "compression"}
|
||||
|
||||
by_model = [{
|
||||
"model": "main-model", "input_tokens": 1000, "output_tokens": 100,
|
||||
"estimated_cost": 0, "sessions": 1, "api_calls": 1,
|
||||
}]
|
||||
merged = _merge_aux_into_by_model(by_model, aux)
|
||||
by_name = {r["model"]: r for r in merged}
|
||||
# vision-only model surfaces as its own entry
|
||||
assert "vision-model" in by_name
|
||||
assert by_name["vision-model"]["input_tokens"] == 300
|
||||
# compression folded into the main model's totals
|
||||
assert by_name["main-model"]["input_tokens"] == 1200
|
||||
assert by_name["main-model"]["api_calls"] == 2
|
||||
|
||||
tasks = _aux_task_summary(aux)
|
||||
assert {t["task"] for t in tasks} == {"vision", "compression"}
|
||||
|
||||
|
||||
class TestInsightsAuxTotals:
|
||||
def test_overview_totals_include_aux_usage(self, db):
|
||||
"""`hermes insights` overview must count aux tokens, not just the
|
||||
sessions counters (issues #58592, #9979)."""
|
||||
from agent.insights import InsightsEngine
|
||||
|
||||
db.create_session("s1", source="cli")
|
||||
db.update_token_counts(
|
||||
"s1", input_tokens=1000, output_tokens=100,
|
||||
model="main-model", billing_provider="nous", api_call_count=1,
|
||||
)
|
||||
db.record_auxiliary_usage(
|
||||
"s1", "compression", model="glm-5",
|
||||
billing_provider="openrouter", input_tokens=5000, output_tokens=500,
|
||||
)
|
||||
report = InsightsEngine(db).generate(days=30)
|
||||
ov = report["overview"]
|
||||
assert ov["total_input_tokens"] == 6000
|
||||
assert ov["total_output_tokens"] == 600
|
||||
models = {m["model"] for m in report["models"]}
|
||||
assert {"main-model", "glm-5"} <= models
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Regression coverage for latency-bounded recent-session browsing."""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _set_activity(db, session_id, when):
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET last_activity_at = ? WHERE id = ?",
|
||||
(when, session_id),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_bounded_recent_uses_effective_activity_index(db):
|
||||
indexes = {
|
||||
row[0]
|
||||
for row in db._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index'"
|
||||
).fetchall()
|
||||
}
|
||||
assert "idx_sessions_effective_activity" in indexes
|
||||
|
||||
|
||||
def test_writable_startup_reconciles_legacy_activity_column_before_index(tmp_path):
|
||||
"""A pre-last_activity_at store must heal through the real startup path."""
|
||||
path = tmp_path / "legacy-state.db"
|
||||
original = SessionDB(path)
|
||||
original.close()
|
||||
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
conn.execute("DROP INDEX IF EXISTS idx_sessions_effective_activity")
|
||||
conn.execute("ALTER TABLE sessions DROP COLUMN last_activity_at")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
healed = SessionDB(path)
|
||||
try:
|
||||
columns = {
|
||||
row[1] for row in healed._conn.execute("PRAGMA table_info(sessions)")
|
||||
}
|
||||
indexes = {
|
||||
row[0]
|
||||
for row in healed._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index'"
|
||||
)
|
||||
}
|
||||
assert "last_activity_at" in columns
|
||||
assert "idx_sessions_effective_activity" in indexes
|
||||
assert healed.list_recent_sessions_bounded(limit=1) == []
|
||||
finally:
|
||||
healed.close()
|
||||
|
||||
|
||||
def test_bounded_recent_orders_by_durable_activity_and_shapes_preview(db):
|
||||
now = time.time()
|
||||
db.create_session("older", source="cli")
|
||||
db.append_message("older", role="user", content="older preview")
|
||||
db.create_session("newer", source="cli")
|
||||
db.append_message("newer", role="user", content="newer preview")
|
||||
_set_activity(db, "older", now - 20)
|
||||
_set_activity(db, "newer", now - 10)
|
||||
|
||||
rows = db.list_recent_sessions_bounded(limit=2)
|
||||
|
||||
assert [row["id"] for row in rows] == ["newer", "older"]
|
||||
assert rows[0]["preview"] == "newer preview"
|
||||
|
||||
|
||||
def test_bounded_recent_maps_recent_compression_tip_to_logical_root(db):
|
||||
now = time.time()
|
||||
db.create_session("root", source="cli")
|
||||
db.append_message("root", role="user", content="root preview")
|
||||
db.end_session("root", "compression")
|
||||
db.create_session("tip", source="cli", parent_session_id="root")
|
||||
db.append_message("tip", role="user", content="tip preview")
|
||||
_set_activity(db, "root", now - 1000)
|
||||
_set_activity(db, "tip", now)
|
||||
|
||||
rows = db.list_recent_sessions_bounded(limit=1)
|
||||
|
||||
assert rows[0]["id"] == "tip"
|
||||
assert rows[0]["_lineage_root_id"] == "root"
|
||||
assert rows[0]["preview"] == "tip preview"
|
||||
|
||||
|
||||
def test_bounded_recent_keeps_reset_child_user_visible(db):
|
||||
now = time.time()
|
||||
db.create_session("before-reset", source="cli", session_key="cli:one")
|
||||
db.end_session("before-reset", "session_reset")
|
||||
db.create_session(
|
||||
"after-reset",
|
||||
source="cli",
|
||||
parent_session_id="before-reset",
|
||||
session_key="cli:one",
|
||||
)
|
||||
db.append_message("after-reset", role="user", content="fresh conversation")
|
||||
_set_activity(db, "after-reset", now)
|
||||
|
||||
rows = db.list_recent_sessions_bounded(limit=5)
|
||||
|
||||
assert "after-reset" in [row["id"] for row in rows]
|
||||
|
||||
|
||||
def test_bounded_recent_keeps_branch_separate_from_compression_parent(db):
|
||||
now = time.time()
|
||||
db.create_session("branch-parent", source="cli")
|
||||
db.end_session("branch-parent", "compression")
|
||||
db.create_session(
|
||||
"branch-child",
|
||||
source="cli",
|
||||
parent_session_id="branch-parent",
|
||||
model_config={"_branched_from": "branch-parent"},
|
||||
)
|
||||
db.append_message("branch-child", role="user", content="branch preview")
|
||||
_set_activity(db, "branch-child", now)
|
||||
|
||||
rows = db.list_recent_sessions_bounded(limit=5)
|
||||
|
||||
branch = next(row for row in rows if row["id"] == "branch-child")
|
||||
assert branch.get("_lineage_root_id") is None
|
||||
|
||||
|
||||
def test_bounded_recent_excludes_delegated_children_and_sources(db):
|
||||
now = time.time()
|
||||
db.create_session("visible", source="cli")
|
||||
db.append_message("visible", role="user", content="visible")
|
||||
_set_activity(db, "visible", now - 1)
|
||||
db.create_session(
|
||||
"delegated",
|
||||
source="cli",
|
||||
model_config={"_delegate_from": "parent"},
|
||||
)
|
||||
db.append_message("delegated", role="user", content="hidden delegate")
|
||||
_set_activity(db, "delegated", now)
|
||||
db.create_session("hidden-source", source="cron")
|
||||
db.append_message("hidden-source", role="user", content="hidden source")
|
||||
_set_activity(db, "hidden-source", now + 1)
|
||||
|
||||
rows = db.list_recent_sessions_bounded(
|
||||
limit=5,
|
||||
exclude_sources=["cron"],
|
||||
)
|
||||
|
||||
assert [row["id"] for row in rows] == ["visible"]
|
||||
|
||||
|
||||
def test_bounded_recent_omits_deep_lineage_when_traversal_cap_is_reached(db):
|
||||
now = time.time()
|
||||
parent = None
|
||||
for i in range(40):
|
||||
sid = f"deep-{i}"
|
||||
db.create_session(sid, source="cli", parent_session_id=parent)
|
||||
if parent is not None:
|
||||
db.end_session(parent, "compression")
|
||||
_set_activity(db, sid, now + i)
|
||||
parent = sid
|
||||
db.create_session("visible-deep-peer", source="cli")
|
||||
_set_activity(db, "visible-deep-peer", now + 100)
|
||||
|
||||
rows = db.list_recent_sessions_bounded(
|
||||
limit=5,
|
||||
candidate_limit=8,
|
||||
lineage_limit=8,
|
||||
)
|
||||
|
||||
assert [row["id"] for row in rows] == ["visible-deep-peer"]
|
||||
|
||||
|
||||
def test_bounded_recent_omits_branching_lineage_at_total_row_cap(db):
|
||||
now = time.time()
|
||||
db.create_session("fanout-root", source="cli")
|
||||
db.end_session("fanout-root", "compression")
|
||||
for i in range(40):
|
||||
sid = f"fanout-{i}"
|
||||
db.create_session(sid, source="cli", parent_session_id="fanout-root")
|
||||
_set_activity(db, sid, now + i)
|
||||
db.create_session("visible-fanout-peer", source="cli")
|
||||
_set_activity(db, "visible-fanout-peer", now + 100)
|
||||
|
||||
rows = db.list_recent_sessions_bounded(
|
||||
limit=5,
|
||||
candidate_limit=8,
|
||||
lineage_limit=8,
|
||||
)
|
||||
|
||||
assert [row["id"] for row in rows] == ["visible-fanout-peer"]
|
||||
|
||||
|
||||
def test_bounded_recent_cycle_is_deduplicated_and_omitted(db):
|
||||
now = time.time()
|
||||
db.create_session("cycle-a", source="cli")
|
||||
db.create_session("cycle-b", source="cli", parent_session_id="cycle-a")
|
||||
db.end_session("cycle-a", "compression")
|
||||
db.end_session("cycle-b", "compression")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET parent_session_id = ? WHERE id = ?",
|
||||
("cycle-b", "cycle-a"),
|
||||
)
|
||||
_set_activity(db, "cycle-a", now)
|
||||
_set_activity(db, "cycle-b", now + 1)
|
||||
db.create_session("visible-cycle-peer", source="cli")
|
||||
_set_activity(db, "visible-cycle-peer", now + 2)
|
||||
|
||||
rows = db.list_recent_sessions_bounded(
|
||||
limit=5,
|
||||
candidate_limit=8,
|
||||
lineage_limit=8,
|
||||
)
|
||||
|
||||
assert [row["id"] for row in rows] == ["visible-cycle-peer"]
|
||||
|
||||
|
||||
def test_bounded_recent_deadline_interrupts_sqlite(db):
|
||||
for i in range(300):
|
||||
sid = f"session-{i}"
|
||||
db.create_session(sid, source="cli")
|
||||
db.append_message(sid, role="user", content=f"message {i}")
|
||||
|
||||
with pytest.raises(TimeoutError, match="recent-session browse exceeded"):
|
||||
db.list_recent_sessions_bounded(
|
||||
limit=20,
|
||||
candidate_limit=300,
|
||||
timeout_seconds=0.0,
|
||||
)
|
||||
|
||||
# The progress handler is removed in finally: the same connection remains
|
||||
# usable after cancellation instead of poisoning subsequent gateway reads.
|
||||
assert db.get_session("session-0")["id"] == "session-0"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""The canonical Bot Chat's title is its identity — renames must be refused.
|
||||
|
||||
Bot Mode resolves a bot's forever-chat by exact-title lookup on
|
||||
(profile, "Bot Chat") every time it opens; there is no session-id pointer.
|
||||
A user rename therefore orphans the whole conversation: resolution misses,
|
||||
the next click mints an empty replacement, and UNIQUE(title) then blocks
|
||||
renaming the original back (#92473).
|
||||
|
||||
The guard lives in SessionDB._set_session_title — the single write path
|
||||
every rename surface funnels through (gateway session.title RPC, /title,
|
||||
CLI rename, REST) — and keys on hidden + exact canonical title so ordinary
|
||||
sessions a user happens to call "Bot Chat" stay freely renameable.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _make_canonical(db, session_id="forever"):
|
||||
db.create_session(session_id, source="desktop")
|
||||
assert db.set_session_title(session_id, SessionDB.CANONICAL_BOT_CHAT_TITLE)
|
||||
assert db.set_session_hidden(session_id, True)
|
||||
return session_id
|
||||
|
||||
|
||||
def test_user_rename_of_canonical_bot_chat_is_refused(db):
|
||||
sid = _make_canonical(db)
|
||||
with pytest.raises(ValueError, match="canonical Bot Chat"):
|
||||
db.set_session_title(sid, "My cool chat")
|
||||
# Identity intact: exact-title lookup still finds the forever chat.
|
||||
row = db.get_session_by_title(SessionDB.CANONICAL_BOT_CHAT_TITLE)
|
||||
assert row and row["id"] == sid
|
||||
|
||||
|
||||
def test_clearing_the_canonical_title_is_refused(db):
|
||||
sid = _make_canonical(db)
|
||||
with pytest.raises(ValueError, match="canonical Bot Chat"):
|
||||
db.set_session_title(sid, "")
|
||||
row = db.get_session_by_title(SessionDB.CANONICAL_BOT_CHAT_TITLE)
|
||||
assert row and row["id"] == sid
|
||||
|
||||
|
||||
def test_rewriting_the_same_canonical_title_is_a_noop_not_an_error(db):
|
||||
# The plugin's eager session.title write re-asserts the canonical title
|
||||
# on creation paths; that must never start failing.
|
||||
sid = _make_canonical(db)
|
||||
assert db.set_session_title(sid, SessionDB.CANONICAL_BOT_CHAT_TITLE)
|
||||
|
||||
|
||||
def test_visible_session_titled_bot_chat_stays_renameable(db):
|
||||
# hidden discriminates the registry row: a normal visible session the
|
||||
# user happened to name "Bot Chat" is not canonical and renames freely.
|
||||
db.create_session("ordinary", source="cli")
|
||||
assert db.set_session_title("ordinary", SessionDB.CANONICAL_BOT_CHAT_TITLE)
|
||||
assert db.set_session_title("ordinary", "renamed away")
|
||||
assert db.get_session("ordinary")["title"] == "renamed away"
|
||||
|
||||
|
||||
def test_auto_titler_still_cannot_touch_the_canonical_row(db):
|
||||
# Pre-existing provenance contract, re-pinned here: user-authority title
|
||||
# outranks derived/llm, so the turn-start auto-titler can never displace
|
||||
# the registry name.
|
||||
sid = _make_canonical(db)
|
||||
assert not db.set_auto_title(sid, "Chat about groceries", source=SessionDB.TITLE_SOURCE_LLM)
|
||||
row = db.get_session_by_title(SessionDB.CANONICAL_BOT_CHAT_TITLE)
|
||||
assert row and row["id"] == sid
|
||||
|
||||
|
||||
def test_auto_titler_cannot_rename_derived_canonical_bot_chat(db):
|
||||
# #99517: the guard must be provenance-blind. A derived (rank 0) canonical
|
||||
# title loses to an llm (rank 1) auto-title on precedence alone, so the
|
||||
# identity check — not precedence — has to stop the write.
|
||||
db.create_session("derived", source="desktop")
|
||||
assert db._set_session_title(
|
||||
"derived",
|
||||
SessionDB.CANONICAL_BOT_CHAT_TITLE,
|
||||
source=SessionDB.TITLE_SOURCE_DERIVED,
|
||||
)
|
||||
assert db.set_session_hidden("derived", True)
|
||||
|
||||
assert not db.set_auto_title(
|
||||
"derived",
|
||||
"Renamed by titler",
|
||||
source=SessionDB.TITLE_SOURCE_LLM,
|
||||
)
|
||||
row = db.get_session("derived")
|
||||
assert row["title"] == SessionDB.CANONICAL_BOT_CHAT_TITLE
|
||||
assert row["title_source"] == SessionDB.TITLE_SOURCE_DERIVED
|
||||
|
||||
|
||||
def test_auto_titler_can_rename_visible_derived_bot_chat(db):
|
||||
# Control: hidden is still the discriminator — a visible session that
|
||||
# merely carries the text "Bot Chat" upgrades derived -> llm as usual.
|
||||
db.create_session("visible", source="desktop")
|
||||
assert db._set_session_title(
|
||||
"visible",
|
||||
SessionDB.CANONICAL_BOT_CHAT_TITLE,
|
||||
source=SessionDB.TITLE_SOURCE_DERIVED,
|
||||
)
|
||||
|
||||
assert db.set_auto_title(
|
||||
"visible",
|
||||
"Renamed by titler",
|
||||
source=SessionDB.TITLE_SOURCE_LLM,
|
||||
)
|
||||
assert db.get_session("visible")["title"] == "Renamed by titler"
|
||||
@@ -0,0 +1,381 @@
|
||||
"""Transactional persistence contracts for composite compaction carriers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.context_compressor import (
|
||||
HISTORICAL_TASK_HEADING,
|
||||
SUMMARY_PREFIX,
|
||||
_MERGED_SUMMARY_DELIMITER,
|
||||
_SUMMARY_END_MARKER,
|
||||
)
|
||||
from hermes_state import (
|
||||
CompressionSessionClosedError,
|
||||
SessionCompressionInProgressError,
|
||||
SessionDB,
|
||||
SessionTurnLeaseLostError,
|
||||
)
|
||||
|
||||
|
||||
def _carrier(ask: str = "REAL ASK") -> str:
|
||||
return (
|
||||
f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n"
|
||||
f"{_SUMMARY_END_MARKER}\n\n{ask}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
state = SessionDB(db_path=tmp_path / "state.db")
|
||||
yield state
|
||||
state.close()
|
||||
|
||||
|
||||
def _session_counts(db: SessionDB, session_id: str) -> tuple[int, int, int]:
|
||||
row = db._conn.execute(
|
||||
"SELECT message_count, tool_call_count, rewind_count "
|
||||
"FROM sessions WHERE id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return row["message_count"], row["tool_call_count"], row["rewind_count"]
|
||||
|
||||
|
||||
def _row_state(db: SessionDB, session_id: str) -> list[tuple]:
|
||||
return [
|
||||
tuple(row)
|
||||
for row in db._conn.execute(
|
||||
"SELECT id, role, content, active, display_kind "
|
||||
"FROM messages WHERE session_id = ? ORDER BY id",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
def _active_ids(db: SessionDB, session_id: str) -> list[int]:
|
||||
return [
|
||||
int(message["_row_id"])
|
||||
for message in db.get_messages_as_conversation(
|
||||
session_id, include_row_ids=True
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_composite_rewind_archives_tail_and_inserts_its_hidden_scaffold(db):
|
||||
sid = "carrier-rewind"
|
||||
db.create_session(sid, source="tui")
|
||||
db.append_message(sid, "user", "older ask")
|
||||
db.append_message(
|
||||
sid,
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[{"id": "call-1", "function": {"name": "terminal"}}],
|
||||
)
|
||||
db.append_message(sid, "tool", "ok", tool_call_id="call-1")
|
||||
target_id = db.append_message(sid, "user", _carrier())
|
||||
db.append_message(sid, "assistant", "failed")
|
||||
expected_active_ids = _active_ids(db, sid)
|
||||
|
||||
result = db.rewind_to_message(
|
||||
sid,
|
||||
target_id,
|
||||
preserve_compaction_handoff=True,
|
||||
expected_active_ids=expected_active_ids,
|
||||
expected_target_content="REAL ASK",
|
||||
)
|
||||
|
||||
assert result["rewound_count"] == 2
|
||||
assert result["replacement_message_id"] == result["new_head_id"]
|
||||
active = db.get_messages_as_conversation(sid, include_row_ids=True)
|
||||
assert len(active) == 4
|
||||
assert active[-1]["_row_id"] == result["replacement_message_id"]
|
||||
assert active[-1]["display_kind"] == "hidden"
|
||||
assert SUMMARY_PREFIX in active[-1]["content"]
|
||||
assert "REAL ASK" not in active[-1]["content"]
|
||||
archived = db._conn.execute(
|
||||
"SELECT active FROM messages WHERE id IN (?, ?) ORDER BY id",
|
||||
(target_id, target_id + 1),
|
||||
).fetchall()
|
||||
assert [row[0] for row in archived] == [0, 0]
|
||||
assert _session_counts(db, sid) == (4, 1, 1)
|
||||
|
||||
|
||||
def test_lineage_display_prefers_tip_carrier_over_replayed_parent_ask(db):
|
||||
parent = "carrier-parent"
|
||||
child = "carrier-child"
|
||||
db.create_session(parent, source="tui")
|
||||
db.append_message(parent, "user", "REAL ASK")
|
||||
db.end_session(parent, "compression")
|
||||
db.create_session(child, source="tui", parent_session_id=parent)
|
||||
carrier_id = db.append_message(child, "user", _carrier())
|
||||
|
||||
model_history, display_history = db.get_resume_conversations(child)
|
||||
|
||||
from agent.context_compressor import user_originated_turn_view
|
||||
|
||||
visible_users = [
|
||||
user_originated_turn_view(message)
|
||||
for message in display_history
|
||||
if user_originated_turn_view(message) is not None
|
||||
]
|
||||
assert [message["content"] for message in visible_users] == ["REAL ASK"]
|
||||
assert display_history[-1]["_row_id"] == carrier_id
|
||||
assert model_history[-1]["_row_id"] == carrier_id
|
||||
assert db.get_ancestor_display_prefix(child) == []
|
||||
|
||||
|
||||
def test_lineage_display_dedupes_multimodal_ask_in_tip_carrier(db):
|
||||
parent = "media-carrier-parent"
|
||||
child = "media-carrier-child"
|
||||
ask = [
|
||||
{"type": "text", "text": "inspect this"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
||||
]
|
||||
carrier = [
|
||||
{"type": "text", "text": f"{_carrier('')}\n"},
|
||||
*ask,
|
||||
]
|
||||
db.create_session(parent, source="tui")
|
||||
db.append_message(parent, "user", ask)
|
||||
db.end_session(parent, "compression")
|
||||
db.create_session(child, source="tui", parent_session_id=parent)
|
||||
carrier_id = db.append_message(child, "user", carrier)
|
||||
|
||||
_, display_history = db.get_resume_conversations(child)
|
||||
|
||||
from agent.context_compressor import user_originated_turn_view
|
||||
|
||||
visible_users = [
|
||||
user_originated_turn_view(message)
|
||||
for message in display_history
|
||||
if user_originated_turn_view(message) is not None
|
||||
]
|
||||
assert [message["content"] for message in visible_users] == [ask]
|
||||
assert display_history[-1]["_row_id"] == carrier_id
|
||||
|
||||
|
||||
def test_default_rewind_return_shape_and_active_counters_remain_compatible(db):
|
||||
sid = "default-rewind"
|
||||
db.create_session(sid, source="cli")
|
||||
db.append_message(sid, "user", "first")
|
||||
db.append_message(sid, "assistant", "answer")
|
||||
target_id = db.append_message(sid, "user", "second")
|
||||
db.append_message(
|
||||
sid,
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[{"id": "call-2", "function": {"name": "terminal"}}],
|
||||
)
|
||||
|
||||
result = db.rewind_to_message(sid, target_id)
|
||||
|
||||
assert set(result) == {"rewound_count", "target_message", "new_head_id"}
|
||||
assert result["rewound_count"] == 2
|
||||
assert _session_counts(db, sid) == (2, 0, 1)
|
||||
|
||||
|
||||
def test_guarded_composite_rewind_rejects_append_without_inserting_scaffold(db):
|
||||
sid = "guarded-rewind-append"
|
||||
db.create_session(sid, source="cli")
|
||||
db.append_message(sid, "user", "first")
|
||||
db.append_message(sid, "assistant", "answer")
|
||||
target_id = db.append_message(sid, "user", _carrier())
|
||||
db.append_message(sid, "assistant", "failed")
|
||||
snapshot = db.get_messages_as_conversation(sid, include_row_ids=True)
|
||||
expected_active_ids = [int(message["_row_id"]) for message in snapshot]
|
||||
assert snapshot[-2]["_row_id"] == target_id
|
||||
|
||||
# Deterministic validation -> write race: a sibling writer commits after
|
||||
# the snapshot but before rewind_to_message begins its write transaction.
|
||||
sibling = SessionDB(db_path=db.db_path)
|
||||
sibling.append_message(sid, "assistant", "concurrent append")
|
||||
sibling.close()
|
||||
before_rows = _row_state(db, sid)
|
||||
before_counts = _session_counts(db, sid)
|
||||
|
||||
with pytest.raises(RuntimeError, match="active transcript changed"):
|
||||
db.rewind_to_message(
|
||||
sid,
|
||||
target_id,
|
||||
preserve_compaction_handoff=True,
|
||||
expected_active_ids=expected_active_ids,
|
||||
expected_target_content=_carrier(),
|
||||
)
|
||||
|
||||
assert _row_state(db, sid) == before_rows
|
||||
assert _session_counts(db, sid) == before_counts
|
||||
|
||||
def test_guarded_rewind_rejects_selected_target_content_change(db):
|
||||
sid = "guarded-rewind-in-place"
|
||||
db.create_session(sid, source="cli")
|
||||
db.append_message(sid, "user", "first")
|
||||
db.append_message(sid, "assistant", "answer")
|
||||
target_id = db.append_message(sid, "user", "second")
|
||||
db.append_message(sid, "assistant", "failed")
|
||||
expected_active_ids = _active_ids(db, sid)
|
||||
|
||||
sibling = SessionDB(db_path=db.db_path)
|
||||
sibling._execute_write(
|
||||
lambda conn: conn.execute(
|
||||
"UPDATE messages SET content = ? WHERE id = ?",
|
||||
("changed second", target_id),
|
||||
)
|
||||
)
|
||||
sibling.close()
|
||||
before_rows = _row_state(db, sid)
|
||||
before_counts = _session_counts(db, sid)
|
||||
|
||||
with pytest.raises(RuntimeError, match="rewind target changed"):
|
||||
db.rewind_to_message(
|
||||
sid,
|
||||
target_id,
|
||||
expected_active_ids=expected_active_ids,
|
||||
expected_target_content="second",
|
||||
)
|
||||
|
||||
assert _row_state(db, sid) == before_rows
|
||||
assert _session_counts(db, sid) == before_counts
|
||||
|
||||
|
||||
def test_guarded_rewind_ignores_reaction_metadata_change(db):
|
||||
sid = "guarded-rewind-reaction"
|
||||
db.create_session(sid, source="cli")
|
||||
target_id = db.append_message(sid, "user", "second")
|
||||
db.append_message(sid, "assistant", "failed")
|
||||
expected_active_ids = _active_ids(db, sid)
|
||||
|
||||
assert db.set_message_reaction(sid, target_id + 1, "👍", author="user")
|
||||
|
||||
result = db.rewind_to_message(
|
||||
sid,
|
||||
target_id,
|
||||
expected_active_ids=expected_active_ids,
|
||||
expected_target_content="second",
|
||||
)
|
||||
|
||||
assert result["rewound_count"] == 2
|
||||
assert db.get_messages_as_conversation(sid) == []
|
||||
|
||||
|
||||
def test_rewind_guard_rejects_foreign_live_compression_without_any_change(db):
|
||||
sid = "locked-rewind"
|
||||
db.create_session(sid, source="tui")
|
||||
target_id = db.append_message(sid, "user", _carrier())
|
||||
db.append_message(sid, "assistant", "failed")
|
||||
assert db.try_acquire_compression_lock(sid, "foreign-writer", ttl_seconds=60)
|
||||
before_rows = _row_state(db, sid)
|
||||
before_counts = _session_counts(db, sid)
|
||||
|
||||
with pytest.raises(SessionCompressionInProgressError):
|
||||
db.rewind_to_message(
|
||||
sid, target_id, preserve_compaction_handoff=True
|
||||
)
|
||||
|
||||
assert _row_state(db, sid) == before_rows
|
||||
assert _session_counts(db, sid) == before_counts
|
||||
|
||||
|
||||
def test_rewind_guard_rejects_foreign_turn_lease_without_any_change(db):
|
||||
sid = "leased-rewind"
|
||||
db.create_session(sid, source="tui")
|
||||
target_id = db.append_message(sid, "user", _carrier())
|
||||
expected_active_ids = _active_ids(db, sid)
|
||||
holder = f"pid={os.getpid()}:turn=active"
|
||||
assert db.try_acquire_session_turn_lease(sid, holder, ttl_seconds=60)
|
||||
before_rows = _row_state(db, sid)
|
||||
before_counts = _session_counts(db, sid)
|
||||
|
||||
with pytest.raises(SessionTurnLeaseLostError, match="active turn lease"):
|
||||
db.rewind_to_message(
|
||||
sid,
|
||||
target_id,
|
||||
preserve_compaction_handoff=True,
|
||||
expected_active_ids=expected_active_ids,
|
||||
expected_target_content="REAL ASK",
|
||||
)
|
||||
|
||||
assert _row_state(db, sid) == before_rows
|
||||
assert _session_counts(db, sid) == before_counts
|
||||
|
||||
db.release_session_turn_lease(sid, holder)
|
||||
result = db.rewind_to_message(
|
||||
sid,
|
||||
target_id,
|
||||
preserve_compaction_handoff=True,
|
||||
expected_active_ids=expected_active_ids,
|
||||
expected_target_content="REAL ASK",
|
||||
)
|
||||
assert result["rewound_count"] == 1
|
||||
|
||||
|
||||
def test_guarded_replace_rejects_foreign_turn_lease_without_any_change(db):
|
||||
sid = "leased-replace"
|
||||
db.create_session(sid, source="tui")
|
||||
db.append_message(sid, "user", "old ask")
|
||||
holder = f"pid={os.getpid()}:turn=active"
|
||||
assert db.try_acquire_session_turn_lease(sid, holder, ttl_seconds=60)
|
||||
before_rows = _row_state(db, sid)
|
||||
before_counts = _session_counts(db, sid)
|
||||
|
||||
with pytest.raises(SessionTurnLeaseLostError, match="active turn lease"):
|
||||
db.replace_messages(
|
||||
sid,
|
||||
[{"role": "user", "content": "replacement"}],
|
||||
active_only=True,
|
||||
archive_dropped=True,
|
||||
reject_active_turn_lease=True,
|
||||
)
|
||||
|
||||
assert _row_state(db, sid) == before_rows
|
||||
assert _session_counts(db, sid) == before_counts
|
||||
|
||||
db.release_session_turn_lease(sid, holder)
|
||||
db.replace_messages(
|
||||
sid,
|
||||
[{"role": "user", "content": "replacement"}],
|
||||
active_only=True,
|
||||
archive_dropped=True,
|
||||
reject_active_turn_lease=True,
|
||||
)
|
||||
assert [m[2] for m in _row_state(db, sid) if m[3] == 1] == ["replacement"]
|
||||
|
||||
|
||||
def test_guarded_replace_rejects_foreign_live_compression_without_any_change(db):
|
||||
sid = "compression-locked-replace"
|
||||
db.create_session(sid, source="tui")
|
||||
db.append_message(sid, "user", "old ask")
|
||||
assert db.try_acquire_compression_lock(sid, "foreign-writer", ttl_seconds=60)
|
||||
before_rows = _row_state(db, sid)
|
||||
before_counts = _session_counts(db, sid)
|
||||
|
||||
with pytest.raises(SessionCompressionInProgressError):
|
||||
db.replace_messages(
|
||||
sid,
|
||||
[{"role": "user", "content": "replacement"}],
|
||||
active_only=True,
|
||||
archive_dropped=True,
|
||||
reject_active_turn_lease=True,
|
||||
)
|
||||
|
||||
assert _row_state(db, sid) == before_rows
|
||||
assert _session_counts(db, sid) == before_counts
|
||||
|
||||
|
||||
def test_rewind_guard_rejects_compression_ended_parent_without_any_change(db):
|
||||
sid = "closed-rewind"
|
||||
db.create_session(sid, source="tui")
|
||||
target_id = db.append_message(sid, "user", _carrier())
|
||||
db.append_message(sid, "assistant", "failed")
|
||||
db.end_session(sid, "compression")
|
||||
before_rows = _row_state(db, sid)
|
||||
before_counts = _session_counts(db, sid)
|
||||
|
||||
with pytest.raises(CompressionSessionClosedError):
|
||||
db.rewind_to_message(
|
||||
sid, target_id, preserve_compaction_handoff=True
|
||||
)
|
||||
|
||||
assert _row_state(db, sid) == before_rows
|
||||
assert _session_counts(db, sid) == before_counts
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for SessionDB.get_conversation_root — stable conversation id resolution.
|
||||
|
||||
The conversation root is the Nous Portal ``conversation=`` tag value: one
|
||||
stable id per user-facing conversation, surviving context-compression
|
||||
session rotation and covering delegate subagent trees.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def test_root_of_standalone_session_is_itself(db):
|
||||
db.create_session("solo", source="cli")
|
||||
assert db.get_conversation_root("solo") == "solo"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_root_covers_delegate_child_sessions(db):
|
||||
db.create_session("parent", source="cli")
|
||||
db.create_session("child", source="delegate", parent_session_id="parent")
|
||||
assert db.get_conversation_root("child") == "parent"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Refuse SessionDB open/write when a deleted WAL generation is still held.
|
||||
|
||||
A live writer that keeps the unlinked ``state.db-wal`` inode while a second
|
||||
opener would mint a fresh WAL is the split-brain that produces intermittent
|
||||
``database disk image is malformed`` / ``disk I/O error``. The store must
|
||||
fail closed on both the open and write paths instead of creating the second
|
||||
generation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import (
|
||||
DeletedWalGenerationError,
|
||||
SessionDB,
|
||||
classify_persistence_error,
|
||||
iter_deleted_sqlite_sidecar_holders,
|
||||
refuse_deleted_wal_generation,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def force_wal(monkeypatch):
|
||||
"""Pin WAL so this host's vulnerable SQLite still matches production topology."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
)
|
||||
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "wal")
|
||||
|
||||
|
||||
def _make_db(path: Path, session_id: str, content: str) -> SessionDB:
|
||||
db = SessionDB(db_path=path)
|
||||
db.create_session(session_id, "cli")
|
||||
db.append_message(session_id, role="user", content=content)
|
||||
return db
|
||||
|
||||
|
||||
def _require_wal(db: SessionDB) -> Path:
|
||||
if not db._wal_active:
|
||||
db.close()
|
||||
pytest.skip("WAL not active on this filesystem")
|
||||
wal = Path(os.fspath(db.db_path) + "-wal")
|
||||
if not wal.exists():
|
||||
db.close()
|
||||
pytest.skip("WAL sidecar missing after first write")
|
||||
return wal
|
||||
|
||||
|
||||
def _unlink_sidecars(db_path: Path) -> None:
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = Path(os.fspath(db_path) + suffix)
|
||||
if sidecar.exists():
|
||||
os.unlink(sidecar)
|
||||
|
||||
|
||||
def test_classify_deleted_wal_is_replaced_not_disk():
|
||||
err = DeletedWalGenerationError(
|
||||
"FATAL: a live process holds a deleted state.db-wal or state.db-shm "
|
||||
"inode while the path names a different (or missing) generation."
|
||||
)
|
||||
assert classify_persistence_error(err) == "replaced"
|
||||
assert classify_persistence_error(str(err)) == "replaced"
|
||||
|
||||
|
||||
def test_iter_holders_empty_on_non_linux(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(hermes_state.sys, "platform", "win32")
|
||||
assert iter_deleted_sqlite_sidecar_holders(tmp_path / "state.db") == []
|
||||
|
||||
|
||||
def test_clean_open_and_second_open_still_work(tmp_path, force_wal):
|
||||
path = tmp_path / "state.db"
|
||||
db = _make_db(path, "s1", "hello")
|
||||
_require_wal(db)
|
||||
db.close()
|
||||
reopened = SessionDB(db_path=path)
|
||||
try:
|
||||
reopened.append_message("s1", role="user", content="second-open")
|
||||
rows = reopened.get_messages("s1")
|
||||
assert any(m["content"] == "second-open" for m in rows)
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_delete_journal_two_writers_still_work(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "delete")
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
)
|
||||
path = tmp_path / "state.db"
|
||||
a = _make_db(path, "s", "from-a")
|
||||
try:
|
||||
assert not Path(os.fspath(path) + "-wal").exists()
|
||||
b = SessionDB(db_path=path)
|
||||
try:
|
||||
b.append_message("s", role="user", content="from-b")
|
||||
contents = [m["content"] for m in b.get_messages("s")]
|
||||
assert "from-a" in contents
|
||||
assert "from-b" in contents
|
||||
finally:
|
||||
b.close()
|
||||
finally:
|
||||
a.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason="deleted-WAL /proc scan is Linux-only",
|
||||
)
|
||||
def test_iter_finds_self_after_wal_unlink(tmp_path, force_wal):
|
||||
path = tmp_path / "state.db"
|
||||
db = _make_db(path, "s", "held")
|
||||
wal = _require_wal(db)
|
||||
inode_before = wal.stat().st_ino
|
||||
_unlink_sidecars(path)
|
||||
holders = iter_deleted_sqlite_sidecar_holders(path)
|
||||
try:
|
||||
assert holders, "expected this process to still hold the deleted WAL inode"
|
||||
assert any("(deleted)" in target for _pid, target in holders)
|
||||
assert any(
|
||||
target.removesuffix(" (deleted)").endswith(("-wal", "-shm"))
|
||||
for _pid, target in holders
|
||||
)
|
||||
assert not wal.exists() or wal.stat().st_ino != inode_before
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason="deleted-WAL /proc scan is Linux-only",
|
||||
)
|
||||
def test_second_sessiondb_open_refuses_and_does_not_mint_wal(tmp_path, force_wal):
|
||||
path = tmp_path / "state.db"
|
||||
writer = _make_db(path, "s", "before-unlink")
|
||||
wal = _require_wal(writer)
|
||||
inode_before = wal.stat().st_ino
|
||||
_unlink_sidecars(path)
|
||||
assert not wal.exists()
|
||||
|
||||
with pytest.raises(DeletedWalGenerationError, match="deleted state.db-wal"):
|
||||
SessionDB(db_path=path)
|
||||
|
||||
assert not wal.exists(), "open must refuse before sqlite3.connect mints a WAL"
|
||||
# If a WAL somehow reappeared it must not be a new generation.
|
||||
if wal.exists():
|
||||
assert wal.stat().st_ino == inode_before
|
||||
writer.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason="deleted-WAL write halt uses Linux unlink semantics",
|
||||
)
|
||||
def test_writer_halts_after_own_wal_unlinked(tmp_path, force_wal):
|
||||
path = tmp_path / "state.db"
|
||||
db = _make_db(path, "s", "before")
|
||||
_require_wal(db)
|
||||
recorded = db._db_sidecar_identity.get("-wal")
|
||||
assert recorded is not None
|
||||
_unlink_sidecars(path)
|
||||
|
||||
with pytest.raises(DeletedWalGenerationError, match="deleted state.db-wal"):
|
||||
db.append_message("s", role="user", content="after-unlink")
|
||||
assert db._db_wal_generation_lost is True
|
||||
|
||||
with pytest.raises(DeletedWalGenerationError):
|
||||
db.append_message("s", role="user", content="second-after-halt")
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason="deleted-WAL /proc scan is Linux-only",
|
||||
)
|
||||
def test_refuse_helper_raises_while_deleted_wal_held(tmp_path, force_wal):
|
||||
path = tmp_path / "state.db"
|
||||
raw = sqlite3.connect(str(path))
|
||||
try:
|
||||
raw.execute("PRAGMA journal_mode=WAL")
|
||||
raw.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
|
||||
raw.execute("INSERT INTO t VALUES (1, 'held')")
|
||||
raw.commit()
|
||||
wal = Path(str(path) + "-wal")
|
||||
assert wal.exists()
|
||||
os.unlink(wal)
|
||||
shm = Path(str(path) + "-shm")
|
||||
if shm.exists():
|
||||
os.unlink(shm)
|
||||
with pytest.raises(DeletedWalGenerationError):
|
||||
refuse_deleted_wal_generation(path)
|
||||
assert not wal.exists()
|
||||
finally:
|
||||
raw.close()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Every display projection of a compacted session must agree.
|
||||
|
||||
In-place compaction archives earlier turns as ``active=0, compacted=1`` rows.
|
||||
They are durable display history — the user's own conversation, still on disk.
|
||||
#80680 taught the REST transcript read to include them, but three GATEWAY
|
||||
display projections kept filtering ``active = 1``:
|
||||
|
||||
- ``get_resume_conversations()`` — what ``session.resume`` ships
|
||||
- ``get_ancestor_display_prefix()`` — the ancestor lineage prefix
|
||||
- ``get_messages_as_conversation()`` — the warm-session payload on tab switch
|
||||
|
||||
So the same conversation read four ways gave two different answers: REST showed
|
||||
everything, the gateway cut the transcript off at the compaction boundary. The
|
||||
user sees their chat "vanish" down to a summary plus a couple of carried-forward
|
||||
turns, and a resumed agent that cannot see its own completed work starts it over
|
||||
(#92080, #93618, #68321).
|
||||
|
||||
These tests assert the INVARIANT — all display reads of one session return the
|
||||
same transcript — rather than any particular row count, and pin the two things
|
||||
that must NOT grow with it: the model-fed projection stays compressed, and
|
||||
soft-deleted Undo/Rewind rows stay hidden.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _compact_in_place(db, sid, *, epochs=3, turns=4, tail_count=2):
|
||||
"""Drive *sid* through repeated in-place compaction, like a long chat."""
|
||||
db.create_session(sid, source="desktop")
|
||||
for epoch in range(epochs):
|
||||
for i in range(turns):
|
||||
db.append_message(sid, "user", f"e{epoch} user {i}")
|
||||
db.append_message(sid, "assistant", f"e{epoch} assistant {i}")
|
||||
live = db.get_messages_as_conversation(sid)
|
||||
db.archive_and_compact(
|
||||
sid,
|
||||
[{"role": "user", "content": f"[summary {epoch}]"}] + live[-tail_count:],
|
||||
tail_count=tail_count,
|
||||
)
|
||||
return sid
|
||||
|
||||
|
||||
def _texts(messages):
|
||||
return [(m["role"], m["content"]) for m in messages]
|
||||
|
||||
|
||||
def _rest_display(db, sid):
|
||||
"""The read that was already correct — the parity reference."""
|
||||
return [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in db.get_messages(sid, include_compacted=True)
|
||||
]
|
||||
|
||||
|
||||
class TestDisplayProjectionParity:
|
||||
def test_resume_display_matches_the_rest_transcript(self, db):
|
||||
sid = _compact_in_place(db, "chat")
|
||||
|
||||
_, display = db.get_resume_conversations(sid)
|
||||
|
||||
assert _texts(display) == _texts(_rest_display(db, sid))
|
||||
|
||||
def test_warm_session_display_matches_the_rest_transcript(self, db):
|
||||
"""The read behind ``_live_visible_history`` (switching back to a tab)."""
|
||||
sid = _compact_in_place(db, "chat")
|
||||
|
||||
warm = db.get_messages_as_conversation(
|
||||
sid, include_ancestors=True, include_row_ids=True, include_compacted=True
|
||||
)
|
||||
|
||||
assert _texts(warm) == _texts(_rest_display(db, sid))
|
||||
|
||||
def test_pre_compaction_turns_survive_in_the_resume_transcript(self, db):
|
||||
"""The user's own first turn is still there after several compactions."""
|
||||
sid = _compact_in_place(db, "chat")
|
||||
|
||||
_, display = db.get_resume_conversations(sid)
|
||||
|
||||
assert ("user", "e0 user 0") in _texts(display)
|
||||
assert ("assistant", "e0 assistant 0") in _texts(display)
|
||||
|
||||
def test_display_read_dedupes_carried_forward_tail(self, db):
|
||||
"""Each logical message appears once, not once per compaction epoch."""
|
||||
sid = _compact_in_place(db, "chat", epochs=4, tail_count=2)
|
||||
|
||||
_, display = db.get_resume_conversations(sid)
|
||||
seen = _texts(display)
|
||||
|
||||
assert len(seen) == len(set(seen))
|
||||
|
||||
|
||||
class TestModelProjectionStaysCompressed:
|
||||
def test_model_history_excludes_archived_rows(self, db):
|
||||
"""Compaction must still do its job: the model gets the compressed set."""
|
||||
sid = _compact_in_place(db, "chat")
|
||||
|
||||
model, display = db.get_resume_conversations(sid)
|
||||
|
||||
assert len(model) < len(display)
|
||||
assert ("user", "e0 user 0") not in _texts(model)
|
||||
|
||||
def test_model_history_matches_the_active_only_read(self, db):
|
||||
sid = _compact_in_place(db, "chat")
|
||||
|
||||
model, _ = db.get_resume_conversations(sid)
|
||||
active_only = db.get_messages_as_conversation(sid, repair_alternation=True)
|
||||
|
||||
assert _texts(model) == _texts(active_only)
|
||||
|
||||
|
||||
class TestSoftDeletedRowsStayHidden:
|
||||
def test_rewound_rows_are_excluded_from_the_display_projections(self, db):
|
||||
"""Undo/Rewind rows (active=0, compacted=0) are NOT display history."""
|
||||
sid = "chat"
|
||||
db.create_session(sid, source="desktop")
|
||||
db.append_message(sid, "user", "kept")
|
||||
db.append_message(sid, "assistant", "kept reply")
|
||||
db.append_message(sid, "user", "taken back")
|
||||
db.append_message(sid, "assistant", "taken back reply")
|
||||
|
||||
rewind_target = next(
|
||||
m for m in reversed(db.get_messages(sid)) if m["role"] == "user"
|
||||
)
|
||||
db.rewind_to_message(sid, rewind_target["id"])
|
||||
|
||||
_, display = db.get_resume_conversations(sid)
|
||||
warm = db.get_messages_as_conversation(
|
||||
sid, include_ancestors=True, include_compacted=True
|
||||
)
|
||||
|
||||
for projection in (display, warm):
|
||||
contents = [c for _, c in _texts(projection)]
|
||||
assert "taken back" not in contents
|
||||
assert "kept" in contents
|
||||
|
||||
|
||||
class TestAncestorPrefix:
|
||||
def test_prefix_includes_a_compacted_ancestor_s_archived_rows(self, db):
|
||||
"""A compression ROTATION's parent still shows its pre-compaction turns."""
|
||||
parent, child = "parent", "child"
|
||||
db.create_session(parent, source="desktop")
|
||||
for i in range(3):
|
||||
db.append_message(parent, "user", f"P user {i}")
|
||||
db.append_message(parent, "assistant", f"P assistant {i}")
|
||||
db.archive_and_compact(parent, [{"role": "user", "content": "[parent summary]"}])
|
||||
|
||||
db.create_session(child, source="desktop", parent_session_id=parent)
|
||||
db.append_message(child, "user", "C user 0")
|
||||
db.append_message(child, "assistant", "C assistant 0")
|
||||
|
||||
prefix = db.get_ancestor_display_prefix(child)
|
||||
_, display = db.get_resume_conversations(child)
|
||||
|
||||
assert ("user", "P user 0") in _texts(prefix)
|
||||
assert ("user", "P user 0") in _texts(display)
|
||||
# The child's own turns belong to the tip, never the ancestor prefix.
|
||||
assert ("user", "C user 0") not in _texts(prefix)
|
||||
|
||||
def test_explicit_branch_has_no_ancestor_prefix(self, db):
|
||||
"""A /branch copy owns its transcript; the live parent must not leak in."""
|
||||
sid = _compact_in_place(db, "chat")
|
||||
db.create_session(
|
||||
"branch",
|
||||
source="desktop",
|
||||
parent_session_id=sid,
|
||||
model_config={"_branched_from": sid},
|
||||
)
|
||||
db.append_message("branch", "user", "branch turn")
|
||||
|
||||
assert db.get_ancestor_display_prefix("branch") == []
|
||||
|
||||
_, display = db.get_resume_conversations("branch")
|
||||
assert _texts(display) == [("user", "branch turn")]
|
||||
|
||||
|
||||
class TestResumeGuardBoundsWhatResumeLoads:
|
||||
def test_guard_counts_the_rows_the_display_read_materializes(self, db):
|
||||
"""The guard must not undercount: it bounds an in-memory materialization."""
|
||||
sid = _compact_in_place(db, "chat", epochs=4)
|
||||
|
||||
_, display = db.get_resume_conversations(sid)
|
||||
|
||||
assert db.get_resume_message_count(sid) >= len(display)
|
||||
|
||||
def test_guard_rejects_a_lineage_over_the_limit(self, db):
|
||||
from hermes_state import SessionResumeTooLargeError
|
||||
|
||||
sid = _compact_in_place(db, "chat", epochs=4)
|
||||
|
||||
with pytest.raises(SessionResumeTooLargeError):
|
||||
db.assert_resume_safe(sid, max_messages=2)
|
||||
|
||||
def test_tip_only_guard_still_bounds_only_the_live_tip(self, db):
|
||||
"""The #4130 carve-out: a healthy compacted chat must stay resumable.
|
||||
|
||||
A well-compressed conversation is exactly the shape compression is
|
||||
meant to produce. Counting its archive against a tip-sized budget is
|
||||
what stranded Bot Chats on "Waking up…"; ``tip_only`` callers never
|
||||
materialize the archive, so they keep the active-only bound.
|
||||
"""
|
||||
sid = _compact_in_place(db, "chat", epochs=4)
|
||||
|
||||
tip_count = db.get_resume_message_count(sid, tip_only=True)
|
||||
|
||||
assert tip_count < db.get_resume_message_count(sid)
|
||||
assert db.assert_resume_safe(sid, max_messages=tip_count, tip_only=True)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""The empty-session sweep must not eat a rewound / compacted transcript (#95868).
|
||||
|
||||
``count_empty_sessions`` / ``delete_empty_sessions`` back the dashboard's
|
||||
"Delete empty (N)" affordance. They used to define "empty" as
|
||||
``sessions.message_count = 0``, which is a denormalized counter over the LIVE
|
||||
(``active = 1``) rows only.
|
||||
|
||||
Two production transcript-rewrite paths reset that counter on purpose while
|
||||
keeping every dropped turn on disk as ``active = 0``:
|
||||
|
||||
* ``replace_messages(..., archive_dropped=True)`` — the rewind / edit /
|
||||
regenerate mode added in #82756 precisely so a user can take a turn back
|
||||
without the rows being unrecoverable. ``prompt.submit`` reaches it with an
|
||||
empty prefix on a confirmed ordinal-0 rewind (regenerating the very first
|
||||
user turn), which is the reachable production shape.
|
||||
* ``archive_and_compact`` — in-place compaction, which archives the
|
||||
pre-compaction transcript under the same session id (#38763). It normally
|
||||
publishes at least a summary row, so it lands on the same shape only when
|
||||
the live set comes back empty; pinned here as defense in depth.
|
||||
|
||||
Either way the row reports ``message_count = 0`` while still holding its
|
||||
entire recoverable history, and those soft-archived rows are the ONLY copy —
|
||||
which is the whole point of the archive-instead-of-delete guarantee that
|
||||
#70516 / #80763 / #82756 were fixed to provide.
|
||||
|
||||
A gateway reload is what makes such a row *eligible*: every detached session
|
||||
gets ``ended_at`` stamped (``end_reason='ws_orphan_reap'``), which satisfies
|
||||
the sweep's ``ended_at IS NOT NULL`` gate. The next "Delete empty" click then
|
||||
hard-deleted the session row AND ``DELETE FROM messages`` — silently, with no
|
||||
log line to trace it by.
|
||||
|
||||
These tests pin the counter drift as real, and pin the sweep's refusal to act
|
||||
on it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
return SessionDB(db_path=tmp_path / "state.db")
|
||||
|
||||
|
||||
def _seed(db, session_id, turns=4):
|
||||
"""A populated, ended desktop chat — the shape #95868 lost."""
|
||||
db.create_session(session_id, source="desktop", model="test-model")
|
||||
for i in range(turns):
|
||||
db.append_message(
|
||||
session_id,
|
||||
role="user" if i % 2 == 0 else "assistant",
|
||||
content=f"turn {i}",
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def _row_counts(db, session_id):
|
||||
"""(message_count column, real rows on disk) for *session_id*."""
|
||||
with db._lock:
|
||||
counter = db._conn.execute(
|
||||
"SELECT message_count FROM sessions WHERE id = ?", (session_id,)
|
||||
).fetchone()["message_count"]
|
||||
rows = db._conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM messages WHERE session_id = ?", (session_id,)
|
||||
).fetchone()["n"]
|
||||
return counter, rows
|
||||
|
||||
|
||||
def test_rewind_to_empty_drifts_the_counter_to_zero(db):
|
||||
"""The premise: an archived-drop rewrite zeroes the counter, keeps the rows."""
|
||||
_seed(db, "rewound")
|
||||
assert _row_counts(db, "rewound") == (4, 4)
|
||||
|
||||
db.replace_messages("rewound", [], archive_dropped=True)
|
||||
|
||||
counter, rows = _row_counts(db, "rewound")
|
||||
assert counter == 0, "message_count tracks the live set only"
|
||||
assert rows == 4, "the dropped turns stay on disk as the recoverable copy"
|
||||
assert len(db.get_messages("rewound", include_inactive=True)) == 4
|
||||
|
||||
|
||||
def test_sweep_spares_a_rewound_session(db):
|
||||
"""A rewound chat is not empty and must survive the sweep."""
|
||||
_seed(db, "rewound")
|
||||
db.replace_messages("rewound", [], archive_dropped=True)
|
||||
# A gateway reload stamps ended_at on every detached session, which is what
|
||||
# makes the row eligible for the sweep in the first place.
|
||||
db.end_session("rewound", end_reason="ws_orphan_reap")
|
||||
|
||||
assert db.count_empty_sessions() == 0
|
||||
assert db.delete_empty_sessions() == 0
|
||||
assert db.get_session("rewound") is not None
|
||||
assert len(db.get_messages("rewound", include_inactive=True)) == 4
|
||||
|
||||
|
||||
def test_sweep_spares_an_in_place_compacted_session(db):
|
||||
"""``archive_and_compact`` leaves the same zero-counter/live-rows shape."""
|
||||
_seed(db, "compacted")
|
||||
db.archive_and_compact("compacted", [])
|
||||
assert _row_counts(db, "compacted") == (0, 4)
|
||||
|
||||
db.end_session("compacted", end_reason="ws_orphan_reap")
|
||||
|
||||
assert db.count_empty_sessions() == 0
|
||||
assert db.delete_empty_sessions() == 0
|
||||
assert db.get_session("compacted") is not None
|
||||
assert len(db.get_messages("compacted", include_inactive=True)) == 4
|
||||
|
||||
|
||||
def test_genuinely_empty_sessions_are_still_swept(db):
|
||||
"""The fix must not neuter the feature: no rows at all is still empty."""
|
||||
db.create_session("ghost", source="desktop")
|
||||
db.end_session("ghost", end_reason="tui_close")
|
||||
|
||||
_seed(db, "populated")
|
||||
db.end_session("populated", end_reason="tui_close")
|
||||
|
||||
assert db.count_empty_sessions() == 1
|
||||
assert db.delete_empty_sessions() == 1
|
||||
assert db.get_session("ghost") is None
|
||||
assert db.get_session("populated") is not None
|
||||
|
||||
|
||||
def test_count_and_delete_agree_on_a_mixed_database(db):
|
||||
"""The button's N and the sweep it triggers must never disagree.
|
||||
|
||||
They read one shared selector; this pins that they still agree once
|
||||
drifted-counter rows are in the mix.
|
||||
"""
|
||||
db.create_session("ghost", source="desktop")
|
||||
db.end_session("ghost", end_reason="tui_close")
|
||||
|
||||
_seed(db, "rewound")
|
||||
db.replace_messages("rewound", [], archive_dropped=True)
|
||||
db.end_session("rewound", end_reason="ws_orphan_reap")
|
||||
|
||||
_seed(db, "live") # never ended — not a candidate either way
|
||||
|
||||
counted = db.count_empty_sessions()
|
||||
assert counted == 1
|
||||
assert db.delete_empty_sessions() == counted
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for SessionDB.get_anchored_view — anchored window + session bookends.
|
||||
|
||||
Used by the discovery shape of session_search: an FTS5 match becomes the
|
||||
anchor, the call returns goal (bookend_start) + match (window) + resolution
|
||||
(bookend_end) in a single round trip, no LLM.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _seed_long_session(db, sid="s1", n=30):
|
||||
"""Create a long session with alternating user/assistant prose. Returns ids ascending."""
|
||||
db.create_session(sid, source="cli")
|
||||
ids = []
|
||||
for i in range(n):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
mid = db.append_message(sid, role=role, content=f"prose msg {i}")
|
||||
ids.append(mid)
|
||||
return ids
|
||||
|
||||
|
||||
class TestWindowAndBookendShape:
|
||||
def test_returns_window_with_bookend_start_and_end(self, db):
|
||||
ids = _seed_long_session(db, n=30)
|
||||
# Anchor mid-session
|
||||
anchor = ids[15]
|
||||
view = db.get_anchored_view("s1", anchor, window=3, bookend=3)
|
||||
assert len(view["window"]) == 7 # ±3 + anchor
|
||||
assert len(view["bookend_start"]) == 3
|
||||
assert len(view["bookend_end"]) == 3
|
||||
# bookend_start is the first 3 ids of the session
|
||||
assert [m["id"] for m in view["bookend_start"]] == ids[:3]
|
||||
# bookend_end is the last 3 ids of the session
|
||||
assert [m["id"] for m in view["bookend_end"]] == ids[-3:]
|
||||
|
||||
def test_window_anchor_marked_correctly(self, db):
|
||||
ids = _seed_long_session(db, n=20)
|
||||
anchor = ids[10]
|
||||
view = db.get_anchored_view("s1", anchor, window=2, bookend=3)
|
||||
# Anchor message is present in the window
|
||||
anchor_msgs = [m for m in view["window"] if m["id"] == anchor]
|
||||
assert len(anchor_msgs) == 1
|
||||
|
||||
|
||||
|
||||
|
||||
class TestRoleFiltering:
|
||||
def test_tool_role_filtered_from_window(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
user_ids = []
|
||||
for i in range(5):
|
||||
user_ids.append(db.append_message("s1", role="user", content=f"u{i}"))
|
||||
db.append_message("s1", role="tool", content=f"tool output {i}", tool_name="x")
|
||||
# Anchor on user message
|
||||
view = db.get_anchored_view("s1", user_ids[2], window=5, bookend=0)
|
||||
# No tool messages should appear in the window
|
||||
roles = [m.get("role") for m in view["window"]]
|
||||
assert "tool" not in roles
|
||||
|
||||
def test_anchor_preserved_even_when_tool_role(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
db.append_message("s1", role="user", content="ask")
|
||||
tool_id = db.append_message("s1", role="tool", content="tool output", tool_name="x")
|
||||
db.append_message("s1", role="user", content="follow-up")
|
||||
# Anchor on the tool message — should still appear despite default filter
|
||||
view = db.get_anchored_view("s1", tool_id, window=5, bookend=0)
|
||||
ids_in_window = [m["id"] for m in view["window"]]
|
||||
assert tool_id in ids_in_window
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TestSessionIsolation:
|
||||
"""Bookends must not cross session boundaries."""
|
||||
|
||||
def test_bookends_only_from_anchor_session(self, db):
|
||||
ids1 = _seed_long_session(db, sid="s1", n=20)
|
||||
_seed_long_session(db, sid="s2", n=20)
|
||||
view = db.get_anchored_view("s1", ids1[10], window=2, bookend=3)
|
||||
# All bookend messages should have session_id = s1 (or session_id col)
|
||||
for m in view["bookend_start"] + view["bookend_end"]:
|
||||
assert m.get("session_id") == "s1"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for SessionDB.get_messages_around (anchored-window primitive).
|
||||
|
||||
Used by session_search both for the discovery shape (FTS5 match as anchor)
|
||||
and the scroll shape (user-supplied anchor). Returns a window of messages
|
||||
around the anchor plus before/after counts so callers can detect session
|
||||
boundaries.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _seed(db, sid="s1", n=10):
|
||||
"""Create session with n alternating user/assistant messages, return ids ascending."""
|
||||
db.create_session(sid, source="cli")
|
||||
ids = []
|
||||
for i in range(n):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
# append_message returns the new id
|
||||
mid = db.append_message(sid, role=role, content=f"msg {i}")
|
||||
ids.append(mid)
|
||||
return ids
|
||||
|
||||
|
||||
class TestBasicWindow:
|
||||
def test_returns_window_around_anchor(self, db):
|
||||
ids = _seed(db, n=10)
|
||||
anchor = ids[5]
|
||||
view = db.get_messages_around("s1", anchor, window=2)
|
||||
# Expected: 2 before + anchor + 2 after = 5 messages
|
||||
msgs = view["window"]
|
||||
assert len(msgs) == 5
|
||||
assert [m["id"] for m in msgs] == [ids[3], ids[4], ids[5], ids[6], ids[7]]
|
||||
assert view["messages_before"] == 2
|
||||
assert view["messages_after"] == 2
|
||||
|
||||
def test_window_zero_returns_only_anchor(self, db):
|
||||
ids = _seed(db, n=5)
|
||||
view = db.get_messages_around("s1", ids[2], window=0)
|
||||
assert len(view["window"]) == 1
|
||||
assert view["window"][0]["id"] == ids[2]
|
||||
assert view["messages_before"] == 0
|
||||
assert view["messages_after"] == 0
|
||||
|
||||
|
||||
|
||||
class TestBoundaryDetection:
|
||||
"""messages_before / messages_after tell the agent it's at start/end."""
|
||||
|
||||
def test_at_session_start_messages_before_is_short(self, db):
|
||||
ids = _seed(db, n=10)
|
||||
# Anchor on first message; ask for window=5
|
||||
view = db.get_messages_around("s1", ids[0], window=5)
|
||||
assert view["messages_before"] == 0 # nothing before the first msg
|
||||
assert view["messages_after"] == 5
|
||||
# window contains anchor + 5 after = 6 messages
|
||||
assert len(view["window"]) == 6
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TestScrollPattern:
|
||||
"""The forward/backward scroll loop the agent will run."""
|
||||
|
||||
def test_scroll_forward_re_anchored_on_last_id(self, db):
|
||||
ids = _seed(db, n=20)
|
||||
anchor = ids[5]
|
||||
v1 = db.get_messages_around("s1", anchor, window=3)
|
||||
last_id = v1["window"][-1]["id"]
|
||||
v2 = db.get_messages_around("s1", last_id, window=3)
|
||||
# Boundary id (last_id) appears in both windows (in v2 it's the anchor)
|
||||
assert last_id in [m["id"] for m in v1["window"]]
|
||||
assert last_id in [m["id"] for m in v2["window"]]
|
||||
# v2's window extends beyond v1
|
||||
assert max(m["id"] for m in v2["window"]) > max(m["id"] for m in v1["window"])
|
||||
|
||||
|
||||
|
||||
class TestContentHydration:
|
||||
def test_content_is_decoded(self, db):
|
||||
ids = _seed(db, n=3)
|
||||
view = db.get_messages_around("s1", ids[1], window=1)
|
||||
for m in view["window"]:
|
||||
assert isinstance(m.get("content"), str)
|
||||
assert m["content"].startswith("msg ")
|
||||
|
||||
def test_tool_calls_deserialized(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
# Message with tool_calls (pass list — append_message JSON-encodes it)
|
||||
tc_payload = [{"id": "t1", "function": {"name": "x", "arguments": "{}"}}]
|
||||
db.append_message("s1", role="assistant", content="", tool_calls=tc_payload)
|
||||
mid = db.append_message("s1", role="tool", content="result", tool_name="x")
|
||||
|
||||
view = db.get_messages_around("s1", mid, window=2)
|
||||
# Find the assistant message with tool_calls
|
||||
asst = [m for m in view["window"] if m.get("role") == "assistant"]
|
||||
assert asst, "expected an assistant message"
|
||||
# tool_calls should be a list after hydration, not a string
|
||||
assert isinstance(asst[0].get("tool_calls"), list)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for SessionDB.get_messages(include_compacted=...).
|
||||
|
||||
In-place compaction archives earlier turns as ``active=0, compacted=1`` rows
|
||||
that are durable display history, not soft-deleted rows. A transcript read
|
||||
that drops them silently cuts the user-visible conversation off at the
|
||||
compaction boundary (#80680): the UI exhausts its active-only window, "Show
|
||||
earlier messages" disappears, and earlier turns become unreachable even
|
||||
though they are still on disk.
|
||||
|
||||
``include_compacted=True`` must surface those rows while still excluding
|
||||
soft-deleted Undo/Rewind rows (``active=0, compacted=0``) — that remains the
|
||||
job of ``include_inactive`` (audit / debug reads).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _seed(db, sid="s1"):
|
||||
"""Session with 4 archived (compacted) turns + 2 live turns + 1 rewound row."""
|
||||
db.create_session(sid, source="cli")
|
||||
old = [
|
||||
{"role": "user", "content": "old q1"},
|
||||
{"role": "assistant", "content": "old a1"},
|
||||
{"role": "user", "content": "old q2"},
|
||||
{"role": "assistant", "content": "old a2"},
|
||||
]
|
||||
db.append_messages_batch(sid, old)
|
||||
db.archive_and_compact(
|
||||
sid,
|
||||
[
|
||||
{"role": "assistant", "content": "summary of old turns"},
|
||||
{"role": "user", "content": "live q1"},
|
||||
{"role": "assistant", "content": "live a1"},
|
||||
],
|
||||
)
|
||||
# Soft-delete the last live user turn (active=0, compacted=0) so every
|
||||
# row class is present: active=1/compacted=0, active=0/compacted=1,
|
||||
# active=0/compacted=0. rewind_to_message requires a user target.
|
||||
live = db.get_messages(sid)
|
||||
user_msg = next(m for m in reversed(live) if m["role"] == "user")
|
||||
db.rewind_to_message(sid, user_msg["id"])
|
||||
return db
|
||||
|
||||
|
||||
def _row_ids(db, sid, **kwargs):
|
||||
return [m["id"] for m in db.get_messages(sid, **kwargs)]
|
||||
|
||||
|
||||
class TestIncludeCompacted:
|
||||
def test_default_returns_only_active_rows(self, db):
|
||||
"""Regression guard: the default read must not change behaviour."""
|
||||
sid = "s1"
|
||||
db = _seed(db, sid)
|
||||
msgs = db.get_messages(sid)
|
||||
assert all(m["active"] for m in msgs)
|
||||
# Only the compaction summary survived (the rewind soft-deleted
|
||||
# the live user turn AND everything after it); the 4 archived rows
|
||||
# stay hidden.
|
||||
assert len(msgs) == 1
|
||||
|
||||
def test_include_compacted_surfaces_archived_rows(self, db):
|
||||
sid = "s1"
|
||||
db = _seed(db, sid)
|
||||
msgs = db.get_messages(sid, include_compacted=True)
|
||||
# 4 archived + 1 live (the summary); the 2 rewound rows are excluded.
|
||||
assert len(msgs) == 5
|
||||
assert all(m["active"] or m["compacted"] for m in msgs)
|
||||
# Archived rows are the oldest — they come first in insertion order.
|
||||
assert msgs[0]["content"] == "old q1"
|
||||
assert msgs[-1]["content"] == "summary of old turns"
|
||||
|
||||
def test_include_compacted_excludes_soft_deleted_rows(self, db):
|
||||
"""Undo/Rewind rows (active=0, compacted=0) stay hidden."""
|
||||
sid = "s1"
|
||||
db = _seed(db, sid)
|
||||
msgs = db.get_messages(sid, include_compacted=True)
|
||||
assert not any(not m["active"] and not m["compacted"] for m in msgs)
|
||||
|
||||
def test_include_inactive_still_returns_everything(self, db):
|
||||
"""Audit semantics are unchanged: include_inactive wins."""
|
||||
sid = "s1"
|
||||
db = _seed(db, sid)
|
||||
msgs = db.get_messages(sid, include_inactive=True)
|
||||
assert len(msgs) == 7 # 4 archived + 1 live + 2 rewound
|
||||
|
||||
def test_latest_page_with_compacted_rows(self, db):
|
||||
"""latest=True pages back from the newest message, still in order."""
|
||||
sid = "s1"
|
||||
db = _seed(db, sid)
|
||||
ids = _row_ids(db, sid, include_compacted=True, latest=True)
|
||||
all_ids = _row_ids(db, sid, include_compacted=True)
|
||||
# The whole display history fits one page; latest pages are returned
|
||||
# in chronological order (offset measured back from the newest row).
|
||||
assert ids == all_ids
|
||||
# A bounded page still lands on the newest rows.
|
||||
tail = db.get_messages(sid, include_compacted=True, latest=True, limit=3)
|
||||
assert [m["id"] for m in tail] == all_ids[-3:]
|
||||
|
||||
def test_pagination_with_compacted_rows(self, db):
|
||||
"""limit/offset pages over the combined display history."""
|
||||
sid = "s1"
|
||||
db = _seed(db, sid)
|
||||
page = db.get_messages(sid, include_compacted=True, limit=3, offset=2)
|
||||
all_ids = _row_ids(db, sid, include_compacted=True)
|
||||
assert [m["id"] for m in page] == all_ids[2:5]
|
||||
|
||||
|
||||
class TestDisplayDedupe:
|
||||
"""Compaction epochs copy the protected tail into each new generation, so
|
||||
the same logical message exists as several rows (identical
|
||||
role/content/timestamp). The display read must surface it exactly once.
|
||||
"""
|
||||
|
||||
def _copy_tail_as_new_generation(self, db, sid, ids):
|
||||
"""Simulate one compaction epoch: duplicate rows as active=0,
|
||||
compacted=1 with the SAME content and timestamp (the real
|
||||
copy-protected-tail behaviour)."""
|
||||
|
||||
def _do(conn):
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO messages
|
||||
(session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, active, compacted)
|
||||
SELECT session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, 0, 1
|
||||
FROM messages
|
||||
WHERE session_id = ? AND id IN ({placeholders})
|
||||
""",
|
||||
[sid, *ids],
|
||||
)
|
||||
|
||||
db._execute_write(_do)
|
||||
|
||||
def test_copied_protected_tail_is_surfaced_once(self, db):
|
||||
"""A message copied across compaction epochs appears exactly once."""
|
||||
sid = "s1"
|
||||
db.create_session(sid, source="cli")
|
||||
db.append_messages_batch(
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "turn 1"},
|
||||
{"role": "assistant", "content": "answer 1"},
|
||||
],
|
||||
)
|
||||
orig = _row_ids(db, sid)
|
||||
self._copy_tail_as_new_generation(db, sid, orig)
|
||||
msgs = db.get_messages(sid, include_compacted=True)
|
||||
# 2 logical messages, not 4 (the copies are duplicates).
|
||||
assert len(msgs) == 2
|
||||
assert [m["content"] for m in msgs] == ["turn 1", "answer 1"]
|
||||
|
||||
def test_dedupe_prefers_live_row_then_newest_generation(self, db):
|
||||
"""When generations conflict, the live row wins; otherwise the newest
|
||||
generation (highest id) wins."""
|
||||
sid = "s1"
|
||||
db.create_session(sid, source="cli")
|
||||
db.append_messages_batch(sid, [{"role": "user", "content": "dup q"}])
|
||||
gen1 = _row_ids(db, sid)
|
||||
self._copy_tail_as_new_generation(db, sid, gen1) # compacted copy
|
||||
msgs = db.get_messages(sid, include_compacted=True)
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["active"] == 1 # live row wins
|
||||
|
||||
# Archive the live row and copy again: the newest compacted copy wins.
|
||||
db._execute_write(
|
||||
lambda conn: conn.execute(
|
||||
"UPDATE messages SET active = 0, compacted = 1 WHERE session_id = ?",
|
||||
[sid],
|
||||
)
|
||||
)
|
||||
self._copy_tail_as_new_generation(db, sid, gen1)
|
||||
newest_id = max(m["id"] for m in db.get_messages(sid, include_inactive=True))
|
||||
msgs = db.get_messages(sid, include_compacted=True)
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["id"] == newest_id # newest generation wins
|
||||
|
||||
def test_dedupe_applies_before_paging(self, db):
|
||||
"""Deduping happens over the full display set, not per page, so
|
||||
offset paging never surfaces a duplicate."""
|
||||
sid = "s1"
|
||||
db.create_session(sid, source="cli")
|
||||
db.append_messages_batch(
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "q1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "user", "content": "q2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
],
|
||||
)
|
||||
orig = _row_ids(db, sid)
|
||||
self._copy_tail_as_new_generation(db, sid, orig)
|
||||
all_ids = _row_ids(db, sid, include_compacted=True)
|
||||
assert len(all_ids) == 4 # deduped, no copies
|
||||
# Paginate past where the copies would have landed.
|
||||
page = db.get_messages(sid, include_compacted=True, limit=2, offset=2)
|
||||
assert [m["id"] for m in page] == all_ids[2:]
|
||||
assert len(page) == 2
|
||||
|
||||
def test_distinct_tool_calls_with_same_content_are_not_merged(self, db):
|
||||
"""Two real tool messages that happen to share role/content/timestamp
|
||||
must stay separate: the dedupe key includes the tool fields, so only
|
||||
genuine compaction copies (which copy those fields verbatim) collapse.
|
||||
"""
|
||||
sid = "s1"
|
||||
db.create_session(sid, source="cli")
|
||||
|
||||
def _seed_tool_rows(conn):
|
||||
ts = 1700000000.0
|
||||
for cid in ("call-1", "call-2"):
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_call_id,"
|
||||
" tool_name, timestamp, active, compacted)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, 1, 0)",
|
||||
(sid, "tool", "identical result", cid, "search", ts),
|
||||
)
|
||||
|
||||
db._execute_write(_seed_tool_rows)
|
||||
msgs = db.get_messages(sid, include_compacted=True)
|
||||
assert len(msgs) == 2
|
||||
assert {m["tool_call_id"] for m in msgs} == {"call-1", "call-2"}
|
||||
|
||||
def test_compaction_copies_of_tool_messages_still_collapse(self, db):
|
||||
"""Tool rows copied by a compaction epoch (identical tool fields) are
|
||||
deduped like any other message, not split by the widened key."""
|
||||
sid = "s1"
|
||||
db.create_session(sid, source="cli")
|
||||
|
||||
def _seed_tool_row(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_call_id,"
|
||||
" tool_name, timestamp, active, compacted)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, 1, 0)",
|
||||
(sid, "tool", "result", "call-1", "search", 1700000000.0),
|
||||
)
|
||||
|
||||
db._execute_write(_seed_tool_row)
|
||||
orig = _row_ids(db, sid)
|
||||
self._copy_tail_as_new_generation(db, sid, orig)
|
||||
msgs = db.get_messages(sid, include_compacted=True)
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["tool_call_id"] == "call-1"
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Subprocess-surviving isolation marker (#82770).
|
||||
|
||||
``PYTEST_CURRENT_TEST`` / ``PYTEST_VERSION`` are pytest's vars: tests that
|
||||
spawn children and rebuild the child environment routinely strip them so the
|
||||
child "looks like a real CLI" — which used to disarm the live-DB guard in the
|
||||
child at the same moment the child lost the ``HERMES_HOME`` redirect. That
|
||||
pairing is exactly how fixture rows (dm:123 / chat-1 / wx-chat) landed in a
|
||||
developer's production state.db.
|
||||
|
||||
``HERMES_TEST_ISOLATION`` is Hermes's own marker: the hermetic conftest
|
||||
exports it (value = the isolation root) before any test module imports, it
|
||||
inherits into children by default, and ``hermes_state`` honors it as a
|
||||
test-context signal. These tests pin all three properties.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_state
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
_CHILD_PROBE = r"""
|
||||
import json, os, sys
|
||||
sys.path.insert(0, {repo!r})
|
||||
import hermes_state as hs
|
||||
fired = False
|
||||
try:
|
||||
hs._ensure_test_isolation(hs._real_platform_state_root() / "state.db")
|
||||
except RuntimeError:
|
||||
fired = True
|
||||
print(json.dumps({{
|
||||
"armed": hs._running_under_pytest(),
|
||||
"fired": fired,
|
||||
}}))
|
||||
"""
|
||||
|
||||
|
||||
def _spawn_probe(env: dict) -> dict:
|
||||
"""Run the guard probe in a real child process with exactly *env*."""
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", _CHILD_PROBE.format(repo=str(REPO_ROOT))],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _minimal_env(**extra) -> dict:
|
||||
"""A rebuilt-from-scratch child env — the residual-bypass pattern."""
|
||||
env = {
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
"HOME": os.environ.get("HOME", ""),
|
||||
"SYSTEMROOT": os.environ.get("SYSTEMROOT", ""), # Windows needs it
|
||||
"LOCALAPPDATA": os.environ.get("LOCALAPPDATA", ""),
|
||||
}
|
||||
env = {k: v for k, v in env.items() if v}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def test_conftest_exports_the_marker():
|
||||
"""The hermetic conftest must export the marker before tests run."""
|
||||
assert os.environ.get("HERMES_TEST_ISOLATION"), (
|
||||
"HERMES_TEST_ISOLATION must be exported by tests/conftest.py so "
|
||||
"subprocess children inherit a test-context signal that survives "
|
||||
"PYTEST_* scrubbing"
|
||||
)
|
||||
|
||||
|
||||
def test_marker_alone_reports_test_context(monkeypatch):
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.delenv("PYTEST_VERSION", raising=False)
|
||||
monkeypatch.setenv("HERMES_TEST_ISOLATION", "/tmp/some-isolation-root")
|
||||
assert hermes_state._running_under_pytest() is True
|
||||
|
||||
|
||||
def test_no_signals_reports_production(monkeypatch):
|
||||
"""Marker absent + PYTEST_* absent = a real user run; guard must not arm
|
||||
off the env (ancestry may still arm it in a real child — not this seam)."""
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.delenv("PYTEST_VERSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_TEST_ISOLATION", raising=False)
|
||||
assert hermes_state._running_under_pytest() is False
|
||||
|
||||
|
||||
def test_child_with_rebuilt_env_keeping_marker_refuses_production_db():
|
||||
"""THE regression: a child whose env was rebuilt from scratch (PYTEST_*
|
||||
stripped, HERMES_HOME lost) but which keeps the marker must still refuse
|
||||
to open the production state.db."""
|
||||
env = _minimal_env(HERMES_TEST_ISOLATION="/tmp/pytest-isolation-root")
|
||||
result = _spawn_probe(env)
|
||||
assert result["armed"] is True
|
||||
assert result["fired"] is True, (
|
||||
"guard did not fire in a marker-carrying child aimed at the "
|
||||
"production state.db — the #82770 escape is open again"
|
||||
)
|
||||
|
||||
|
||||
def test_child_bypass_env_disarms_guard_even_with_marker():
|
||||
"""The sanctioned escape hatch for children that genuinely need a real
|
||||
DB: HERMES_STATE_DB_GUARD_BYPASS=1, not marker-stripping."""
|
||||
env = _minimal_env(
|
||||
HERMES_TEST_ISOLATION="/tmp/pytest-isolation-root",
|
||||
HERMES_STATE_DB_GUARD_BYPASS="1",
|
||||
)
|
||||
result = _spawn_probe(env)
|
||||
assert result["fired"] is False
|
||||
|
||||
|
||||
def test_child_inheriting_full_test_env_refuses_production_db():
|
||||
"""Default inheritance (env=None equivalent): everything rides along."""
|
||||
result = _spawn_probe(dict(os.environ))
|
||||
assert result["armed"] is True
|
||||
assert result["fired"] is True
|
||||
@@ -0,0 +1,160 @@
|
||||
"""The live-DB guard must survive a scrubbed child environment (#82770).
|
||||
|
||||
Forensic background: a read-only sweep of production ``state.db`` files found
|
||||
hundreds of zero-message "open" gateway session rows carrying test-fixture
|
||||
identities (``chat-1`` / ``user-1`` / ``wx-chat``), with matching
|
||||
``gateway_routing`` scopes pointing at ``pytest-of-*`` temp directories.
|
||||
|
||||
The escape is structural, not a one-off test bug. Hermetic isolation rides
|
||||
entirely on the process environment: ``HERMES_HOME`` says *where* to write and
|
||||
``PYTEST_CURRENT_TEST`` / ``PYTEST_VERSION`` say *whether the guard is armed*.
|
||||
Both live in the same carrier, so a child spawned with a rebuilt environment
|
||||
loses them together — it aims at the developer's real ``state.db`` and
|
||||
silences the only check that would have stopped it, in one step.
|
||||
|
||||
Process ancestry is the signal that survives an env rebuild, so these tests
|
||||
pin that the guard is armed by ancestry when the environment no longer says
|
||||
"pytest".
|
||||
|
||||
These tests drive ``_ensure_test_isolation`` rather than constructing a real
|
||||
``SessionDB``: if the guard regresses, the assertion must fail *without* the
|
||||
test itself writing to the developer's live database.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Probe run in the child: resolve the REAL platform state root (not a
|
||||
# hardcoded ~/.hermes — that root is %LOCALAPPDATA%\hermes on Windows) and
|
||||
# report whether the guard refuses it.
|
||||
_CHILD_PROBE = """
|
||||
import sys
|
||||
sys.path.insert(0, {repo!r})
|
||||
import hermes_state
|
||||
|
||||
root = hermes_state._real_platform_state_root()
|
||||
if root is None:
|
||||
print("NO-ROOT")
|
||||
else:
|
||||
try:
|
||||
hermes_state._ensure_test_isolation(root / "state.db")
|
||||
except RuntimeError:
|
||||
print("REFUSED")
|
||||
else:
|
||||
print("ALLOWED")
|
||||
"""
|
||||
|
||||
|
||||
def _scrubbed_env(**overrides):
|
||||
"""The environment a rebuilt-from-scratch child spawn ends up with.
|
||||
|
||||
Also strips ``HERMES_TEST_ISOLATION`` — the conftest-exported marker
|
||||
layer would otherwise arm the guard first and these tests would no
|
||||
longer prove anything about the ancestry fallback they exist to pin.
|
||||
"""
|
||||
env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if not k.startswith("PYTEST_")
|
||||
and k not in ("HERMES_HOME", "HERMES_TEST_ISOLATION")
|
||||
}
|
||||
env.update(overrides)
|
||||
return env
|
||||
|
||||
|
||||
def _run_probe(env):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _CHILD_PROBE.format(repo=str(REPO_ROOT))],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=120,
|
||||
)
|
||||
verdict = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
|
||||
if verdict == "NO-ROOT":
|
||||
pytest.skip("no real platform state root resolvable on this machine")
|
||||
assert verdict in ("REFUSED", "ALLOWED"), (
|
||||
f"probe produced no verdict.\nstdout={result.stdout!r}\n"
|
||||
f"stderr={result.stderr!r}"
|
||||
)
|
||||
return verdict
|
||||
|
||||
|
||||
class TestScrubbedChildEnvironment:
|
||||
def test_child_without_pytest_env_still_refuses_production_db(self):
|
||||
"""The #82770 escape: no PYTEST_* and no HERMES_HOME, yet still a test.
|
||||
|
||||
This is the exact shape of the leak — the child resolves the real
|
||||
``state.db`` because ``HERMES_HOME`` is gone, and the env-only guard
|
||||
sees a "normal user run" because ``PYTEST_*`` is gone with it.
|
||||
"""
|
||||
assert _run_probe(_scrubbed_env()) == "REFUSED"
|
||||
|
||||
def test_child_inheriting_pytest_env_still_refuses_production_db(self):
|
||||
"""The pre-existing env path must keep working unchanged."""
|
||||
env = dict(os.environ)
|
||||
env.pop("HERMES_HOME", None)
|
||||
env.setdefault("PYTEST_CURRENT_TEST", "tests/x.py::test_x (call)")
|
||||
assert _run_probe(env) == "REFUSED"
|
||||
|
||||
def test_env_bypass_lets_a_deliberate_child_through(self):
|
||||
"""A test that genuinely needs the live DB in a child can opt out.
|
||||
|
||||
``_STATE_DB_GUARD_BYPASS`` is a module global and cannot cross a
|
||||
process boundary, so ancestry-armed children need an env-carried
|
||||
escape hatch or they would have no way to opt out at all.
|
||||
"""
|
||||
env = _scrubbed_env(**{hermes_state._STATE_DB_GUARD_BYPASS_ENV: "1"})
|
||||
assert _run_probe(env) == "ALLOWED"
|
||||
|
||||
|
||||
class TestPytestProcessRecognition:
|
||||
"""Unit-level checks for the ancestry predicate's matching rules."""
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, cmdline):
|
||||
self._cmdline = cmdline
|
||||
|
||||
def cmdline(self):
|
||||
return self._cmdline
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmdline",
|
||||
[
|
||||
["/usr/bin/python", "-m", "pytest", "tests/"],
|
||||
["/venv/bin/pytest", "-q"],
|
||||
[r"C:\venv\Scripts\pytest.exe", "-q"],
|
||||
["/usr/bin/py.test", "tests/"],
|
||||
],
|
||||
)
|
||||
def test_recognises_pytest_invocations(self, cmdline):
|
||||
assert hermes_state._process_looks_like_pytest(self._FakeProc(cmdline))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmdline",
|
||||
[
|
||||
["hermes", "gateway", "start"],
|
||||
["/usr/bin/python", "-m", "hermes_cli.main", "sessions", "list"],
|
||||
# A path that merely *contains* "pytest" is not a pytest process:
|
||||
# tmp paths like /tmp/pytest-of-dev/... show up in real argv.
|
||||
["hermes", "run", "--file", "/tmp/pytest-of-dev/test0/input.txt"],
|
||||
],
|
||||
)
|
||||
def test_ignores_non_pytest_invocations(self, cmdline):
|
||||
assert not hermes_state._process_looks_like_pytest(self._FakeProc(cmdline))
|
||||
|
||||
def test_unreadable_process_is_not_pytest(self):
|
||||
class _Denied:
|
||||
def cmdline(self):
|
||||
raise PermissionError("access denied")
|
||||
|
||||
assert not hermes_state._process_looks_like_pytest(_Denied())
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Behavioral tests for the live-DB test-isolation guard.
|
||||
|
||||
Forensic background (Aug 2026): pytest fixture rows (chat-1 / wx-chat
|
||||
sessions, gateway_routing scopes under /tmp/pytest-of-*) were found in the
|
||||
developer's REAL ~/.hermes/state.db, and a pytest-spawned process flipped
|
||||
the journal mode under the WAL-mode gateway writer, destroying committed
|
||||
transcripts. The guard under test makes any pytest-context ``SessionDB``
|
||||
construction that resolves to a production state.db fail hard instead of
|
||||
falling through.
|
||||
|
||||
These tests are behavioral: they construct real ``SessionDB`` objects (or
|
||||
drive the real guard function) and assert outcomes — no source reading.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from gateway.config import GatewayConfig
|
||||
from gateway.session import SessionStore
|
||||
from hermes_state import SessionDB
|
||||
|
||||
# Must match the root the guard itself computes. Hardcoding ``~/.hermes``
|
||||
# silently disarmed every assertion below on Windows, where the real root is
|
||||
# ``%LOCALAPPDATA%\hermes``: the paths under test were then *correctly*
|
||||
# classified as non-production, so the guard never raised and the whole
|
||||
# TestProductionPathRefused class failed for the wrong reason (#82770).
|
||||
REAL_ROOT = hermes_state._real_platform_state_root()
|
||||
if REAL_ROOT is None: # pragma: no cover - no resolvable home on this platform
|
||||
pytest.skip(
|
||||
"no real platform state root to assert against", allow_module_level=True
|
||||
)
|
||||
|
||||
|
||||
class TestProductionPathRefused:
|
||||
def test_explicit_production_db_path_raises(self):
|
||||
"""SessionDB pointed at the real ~/.hermes/state.db must fail hard."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
SessionDB(db_path=REAL_ROOT / "state.db")
|
||||
|
||||
def test_production_profile_db_path_raises(self):
|
||||
"""Profile homes under the real root are production too."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
SessionDB(db_path=REAL_ROOT / "profiles" / "work" / "state.db")
|
||||
|
||||
def test_read_only_open_of_production_db_raises(self):
|
||||
"""Read-only opens are refused too — tests must not READ live data."""
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
SessionDB(db_path=REAL_ROOT / "state.db", read_only=True)
|
||||
|
||||
def test_unnormalized_production_path_raises(self):
|
||||
"""Symlink-free but unnormalized spellings still resolve and refuse."""
|
||||
sneaky = REAL_ROOT.parent / "subdir" / ".." / REAL_ROOT.name / "state.db"
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
SessionDB(db_path=sneaky)
|
||||
|
||||
def test_default_resolution_to_production_raises(self, monkeypatch):
|
||||
"""The argless-construction path is guarded, not just explicit paths.
|
||||
|
||||
Simulates the escape vector: HERMES_HOME leaked/reset to the real
|
||||
home (subprocess child, stale worktree, gateway-launched shell) so
|
||||
``_default_db_path()`` resolves the production DB.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(REAL_ROOT))
|
||||
# Neutralize the conftest's DEFAULT_DB_PATH re-pin so the default
|
||||
# resolver follows the (production-pointing) env, as it would in a
|
||||
# process that never imported the hermetic conftest.
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
SessionDB()
|
||||
|
||||
|
||||
class TestHermeticPathsAllowed:
|
||||
def test_tmp_db_path_works(self, tmp_path):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
try:
|
||||
db.create_session("iso-guard-session", "cli")
|
||||
assert db.get_session("iso-guard-session") is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_tmp_hermes_home_default_resolution_works(self, tmp_path, monkeypatch):
|
||||
"""Argless SessionDB() under a hermetic HERMES_HOME must succeed."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermetic-home"))
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH
|
||||
)
|
||||
db = SessionDB()
|
||||
try:
|
||||
assert str(tmp_path) in str(db.db_path)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class TestBypassMarker:
|
||||
@pytest.mark.live_system_guard_bypass
|
||||
def test_bypass_marker_disables_state_db_guard(self):
|
||||
"""The established escape-hatch marker must let production paths pass.
|
||||
|
||||
Drives the guard function directly (never actually opens the live
|
||||
DB) — with the bypass marker active it must not raise.
|
||||
"""
|
||||
hermes_state._ensure_test_isolation(REAL_ROOT / "state.db")
|
||||
|
||||
|
||||
class TestSessionStoreLoudFailure:
|
||||
def test_guard_error_is_not_swallowed_into_jsonl_fallback(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""SessionStore must re-raise the guard error, not degrade to JSONL.
|
||||
|
||||
The historical failure mode: SessionDB() blew up (or silently
|
||||
opened the live DB) inside SessionStore.__init__'s blanket
|
||||
``except Exception`` and the gateway carried on. A guard trip must
|
||||
be loud.
|
||||
"""
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError(
|
||||
"live-system guard: test attempted to open production state.db"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(hermes_state, "SessionDB", _boom)
|
||||
with pytest.raises(RuntimeError, match="live-system guard"):
|
||||
SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
|
||||
|
||||
def test_ordinary_db_failure_still_degrades_to_jsonl(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Non-guard SQLite failures keep the existing graceful fallback."""
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("disk on fire")
|
||||
|
||||
monkeypatch.setattr(hermes_state, "SessionDB", _boom)
|
||||
store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
|
||||
assert store._db is None
|
||||
|
||||
|
||||
class TestSubprocessChildCovered:
|
||||
def test_child_without_hermes_home_is_refused(self, tmp_path):
|
||||
"""A subprocess child of a test (no HERMES_HOME) must be blocked.
|
||||
|
||||
This is the real leak vector: tests spawning ``python -m ...``
|
||||
children that never import the hermetic conftest. The guard is
|
||||
env-activated (PYTEST_CURRENT_TEST / PYTEST_VERSION are inherited),
|
||||
so the child's argless SessionDB() must fail hard instead of
|
||||
opening the developer's real state.db.
|
||||
"""
|
||||
env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if k not in ("HERMES_HOME", "PYTEST_PLUGINS", "PYTHONPATH")
|
||||
}
|
||||
env["PYTEST_CURRENT_TEST"] = "tests/fake.py::test_child (call)"
|
||||
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[2])
|
||||
code = (
|
||||
"from hermes_state import SessionDB\n"
|
||||
"SessionDB()\n"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
assert "live-system guard" in proc.stderr
|
||||
|
||||
def test_child_with_tmp_hermes_home_succeeds(self, tmp_path):
|
||||
"""Same child, hermetic HERMES_HOME: must work — no false positive."""
|
||||
env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if k not in ("PYTEST_PLUGINS", "PYTHONPATH")
|
||||
}
|
||||
env["PYTEST_CURRENT_TEST"] = "tests/fake.py::test_child (call)"
|
||||
env["HERMES_HOME"] = str(tmp_path / "child-home")
|
||||
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[2])
|
||||
code = (
|
||||
"from hermes_state import SessionDB\n"
|
||||
"db = SessionDB()\n"
|
||||
"db.close()\n"
|
||||
"print('OK', db.db_path)\n"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert "OK" in proc.stdout
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Named-profile agents must FAIL CLOSED when their profile state.db won't open.
|
||||
|
||||
Desktop Bot Mode / app-global remote mode talks to a single TUI backend while
|
||||
stamping ``profile_home`` for a named profile. The deferred agent build opens
|
||||
that profile's ``state.db`` and hands it to ``_make_agent``. If that open used
|
||||
to fail, the build silently fell back to the launch handle (``session_db =
|
||||
None`` → ``_make_agent``'s ``_get_db()`` default), so the session's rows and
|
||||
messages bled into the wrong profile's store exactly when the profile store
|
||||
was briefly unopenable — and opening the named profile looked blank.
|
||||
|
||||
These tests pin the fail-closed contract: an unopenable profile store raises a
|
||||
clear error (no agent turn), and NO path — deferred build or the
|
||||
``_init_session`` cwd hydration — touches the launch ``state.db`` instead.
|
||||
|
||||
#88532 made SessionStore follow HERMES_HOME; this covers the TUI/agent
|
||||
SessionDB handle. Related to #87723 and #89789.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def homes(tmp_path, monkeypatch):
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "worker"
|
||||
root.mkdir(parents=True)
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH)
|
||||
return root, profile
|
||||
|
||||
|
||||
def _ids(db_path: Path) -> set[str]:
|
||||
if not db_path.exists():
|
||||
return set()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
try:
|
||||
return {row[0] for row in conn.execute("SELECT id FROM sessions")}
|
||||
except sqlite3.OperationalError:
|
||||
return set()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_store_unopenable(profile: Path) -> Path:
|
||||
"""A directory named state.db — SessionDB cannot open it. Real, no mocks."""
|
||||
store = profile / "state.db"
|
||||
store.mkdir()
|
||||
return store
|
||||
|
||||
|
||||
def test_open_profile_session_db_returns_profile_handle(homes):
|
||||
root, profile = homes
|
||||
from tui_gateway import server
|
||||
|
||||
db = server._open_profile_session_db(str(profile))
|
||||
try:
|
||||
db.create_session("20260820_tui_worker", "tui", profile_name="worker")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert _ids(profile / "state.db") == {"20260820_tui_worker"}
|
||||
assert _ids(root / "state.db") == set()
|
||||
|
||||
|
||||
def test_open_profile_session_db_raises_when_store_unopenable(homes):
|
||||
"""A genuinely unopenable state.db (directory in its place) must raise."""
|
||||
root, profile = homes
|
||||
from tui_gateway import server
|
||||
|
||||
_make_store_unopenable(profile)
|
||||
with pytest.raises(RuntimeError, match="profile session store unavailable"):
|
||||
server._open_profile_session_db(str(profile))
|
||||
assert _ids(root / "state.db") == set()
|
||||
|
||||
|
||||
def test_open_profile_session_db_does_not_fallback_on_open_failure(homes, monkeypatch):
|
||||
"""SessionDB constructor failure must raise, not reuse/return launch."""
|
||||
root, profile = homes
|
||||
from tui_gateway import server
|
||||
|
||||
class Boom(Exception):
|
||||
pass
|
||||
|
||||
def boom_db(**_kwargs):
|
||||
raise Boom("profile store unavailable")
|
||||
|
||||
monkeypatch.setattr("hermes_state.SessionDB", boom_db)
|
||||
with pytest.raises(RuntimeError, match="profile session store unavailable") as ei:
|
||||
server._open_profile_session_db(str(profile))
|
||||
assert isinstance(ei.value.__cause__, Boom)
|
||||
assert _ids(root / "state.db") == set()
|
||||
|
||||
|
||||
def test_deferred_build_fails_closed_when_profile_store_unopenable(homes, monkeypatch):
|
||||
"""_start_agent_build must error out — never build against the launch DB.
|
||||
|
||||
Before the fix, the deferred build swallowed the open failure
|
||||
(``except Exception: session_db = None``) and _make_agent bound the
|
||||
launch ``_get_db()`` handle: the agent ran, and every turn landed in the
|
||||
wrong profile's state.db. Now the build must record a clear agent_error,
|
||||
emit an error event, and never reach _make_agent.
|
||||
"""
|
||||
root, profile = homes
|
||||
from tui_gateway import server
|
||||
|
||||
_make_store_unopenable(profile)
|
||||
launch = SessionDB(db_path=root / "state.db")
|
||||
sid = "sid-worker-build"
|
||||
make_agent_calls: list[dict] = []
|
||||
events: list[tuple[str, str, dict | None]] = []
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: launch)
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda *a, **kw: [])
|
||||
monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
|
||||
monkeypatch.setattr(
|
||||
server, "_emit", lambda event, _sid, payload=None: events.append((event, _sid, payload))
|
||||
)
|
||||
|
||||
def fake_make_agent(_sid, _key, **kw):
|
||||
make_agent_calls.append(kw)
|
||||
return SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(server, "_make_agent", fake_make_agent)
|
||||
|
||||
session = {
|
||||
"session_key": "key-worker-build",
|
||||
"agent_ready": threading.Event(),
|
||||
"profile_home": str(profile),
|
||||
}
|
||||
with server._sessions_lock:
|
||||
server._sessions[sid] = session
|
||||
try:
|
||||
server._start_agent_build(sid, session)
|
||||
thread = session.get("_agent_build_thread")
|
||||
assert thread is not None
|
||||
thread.join(timeout=30)
|
||||
assert not thread.is_alive()
|
||||
assert session["agent_ready"].is_set()
|
||||
|
||||
# FAIL CLOSED: no agent was built at all — especially not one bound
|
||||
# to the launch handle.
|
||||
assert make_agent_calls == []
|
||||
assert session.get("agent") is None
|
||||
assert "profile session store unavailable" in str(session.get("agent_error"))
|
||||
assert any(
|
||||
evt == "error" and "agent init failed" in str((payload or {}).get("message"))
|
||||
for evt, _s, payload in events
|
||||
)
|
||||
# And nothing bled into the launch store.
|
||||
assert _ids(root / "state.db") == set()
|
||||
finally:
|
||||
with server._sessions_lock:
|
||||
server._sessions.pop(sid, None)
|
||||
launch.close()
|
||||
|
||||
|
||||
def test_init_session_skips_launch_db_when_profile_store_unopenable(homes, monkeypatch):
|
||||
"""Sibling site: _init_session's cwd hydration must not fall back either.
|
||||
|
||||
Before the fix, ``except Exception: db = _get_db()`` hydrated/persisted a
|
||||
named-profile session's cwd row against the launch state.db. _get_db is
|
||||
patched to a tripwire: any call proves the launch store was touched.
|
||||
"""
|
||||
root, profile = homes
|
||||
from tui_gateway import server
|
||||
|
||||
_make_store_unopenable(profile)
|
||||
sid = "sid-worker-init"
|
||||
|
||||
def tripwire():
|
||||
raise AssertionError("launch _get_db() must not be touched for a named-profile session")
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", tripwire)
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
|
||||
monkeypatch.setattr(server, "_start_notification_poller", lambda _sid, _s: threading.Event())
|
||||
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent, _s=None: {})
|
||||
monkeypatch.setattr(server, "_schedule_mcp_late_refresh", lambda _sid, _agent: None)
|
||||
monkeypatch.setattr(server, "_register_session_cwd", lambda _s: None)
|
||||
monkeypatch.setattr(server, "_load_show_reasoning", lambda: False)
|
||||
monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "off")
|
||||
monkeypatch.setattr(server, "_load_memory_notifications", lambda: "off")
|
||||
|
||||
try:
|
||||
server._init_session(
|
||||
sid,
|
||||
"key-worker-init",
|
||||
SimpleNamespace(),
|
||||
[],
|
||||
cwd=str(root),
|
||||
profile_home=str(profile),
|
||||
)
|
||||
with server._sessions_lock:
|
||||
assert sid in server._sessions
|
||||
assert _ids(root / "state.db") == set()
|
||||
finally:
|
||||
with server._sessions_lock:
|
||||
server._sessions.pop(sid, None)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Sweeping never-active keyed gateway rows (#82770).
|
||||
|
||||
The live-DB guard stops *new* fixture escapes, but it cannot touch rows that
|
||||
are already in a developer's ``state.db`` — and bulk prune/archive cannot
|
||||
either: their shared selector is pinned to ``ended_at IS NOT NULL`` so a live
|
||||
session is never picked, which permanently excludes every never-closed row.
|
||||
|
||||
These tests pin the narrow selector that reaches them, and — more importantly
|
||||
— pin the rows it must refuse to touch.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
DAY = 86400.0
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
return SessionDB(db_path=tmp_path / "state.db")
|
||||
|
||||
|
||||
def _insert(db, session_id, *, age_days, **overrides):
|
||||
"""Insert a keyed gateway row directly, defaulting to the junk shape."""
|
||||
row = {
|
||||
"id": session_id,
|
||||
"source": "telegram",
|
||||
"user_id": "user-1",
|
||||
"session_key": f"agent:main:telegram:dm:{session_id}",
|
||||
"chat_id": "chat-1",
|
||||
"chat_type": "dm",
|
||||
"started_at": time.time() - age_days * DAY,
|
||||
"message_count": 0,
|
||||
"tool_call_count": 0,
|
||||
"api_call_count": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"archived": 0,
|
||||
"pinned": 0,
|
||||
}
|
||||
row.update(overrides)
|
||||
cols = ", ".join(row)
|
||||
placeholders = ", ".join("?" for _ in row)
|
||||
db._conn.execute(
|
||||
f"INSERT INTO sessions ({cols}) VALUES ({placeholders})", list(row.values())
|
||||
)
|
||||
db._conn.commit()
|
||||
return session_id
|
||||
|
||||
|
||||
class TestSelector:
|
||||
def test_selects_old_never_active_keyed_row(self, db):
|
||||
_insert(db, "junk-old", age_days=45)
|
||||
found = db.list_never_active_keyed_sessions(older_than_days=30)
|
||||
assert [r["id"] for r in found] == ["junk-old"]
|
||||
|
||||
def test_ignores_row_inside_the_age_floor(self, db):
|
||||
_insert(db, "junk-young", age_days=3)
|
||||
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
|
||||
|
||||
def test_ignores_unkeyed_row(self, db):
|
||||
_insert(db, "cli-row", age_days=45, session_key=None, source="cli")
|
||||
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
|
||||
|
||||
def test_ignores_ended_row(self, db):
|
||||
"""Ended rows belong to ordinary prune — this selector must not
|
||||
double-claim them."""
|
||||
_insert(db, "ended", age_days=45, ended_at=time.time() - 40 * DAY)
|
||||
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, value",
|
||||
[
|
||||
("message_count", 1),
|
||||
("tool_call_count", 1),
|
||||
("api_call_count", 1),
|
||||
("input_tokens", 12),
|
||||
("output_tokens", 12),
|
||||
("title", "kept by the user"),
|
||||
("last_activity_at", 1785354069.0),
|
||||
("pinned", 1),
|
||||
("archived", 1),
|
||||
],
|
||||
)
|
||||
def test_any_sign_of_use_or_intent_protects_the_row(self, db, field, value):
|
||||
_insert(db, "used", age_days=45, **{field: value})
|
||||
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
|
||||
|
||||
def test_row_with_messages_is_protected_even_if_counter_says_zero(self, db):
|
||||
"""``message_count`` is a denormalised counter — trust the messages."""
|
||||
_insert(db, "stale-counter", age_days=45)
|
||||
db._conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
("stale-counter", "user", "hello", time.time() - 44 * DAY),
|
||||
)
|
||||
db._conn.commit()
|
||||
assert db.list_never_active_keyed_sessions(older_than_days=30) == []
|
||||
|
||||
|
||||
class TestPrune:
|
||||
def test_deletes_candidates_and_leaves_everything_else(self, db):
|
||||
_insert(db, "junk-a", age_days=45)
|
||||
_insert(db, "junk-b", age_days=60)
|
||||
_insert(db, "keeper-young", age_days=1)
|
||||
_insert(db, "keeper-used", age_days=45, message_count=3)
|
||||
|
||||
deleted, _ = db.prune_never_active_keyed_sessions(older_than_days=30)
|
||||
|
||||
assert deleted == 2
|
||||
surviving = {
|
||||
r[0] for r in db._conn.execute("SELECT id FROM sessions").fetchall()
|
||||
}
|
||||
assert surviving == {"keeper-young", "keeper-used"}
|
||||
|
||||
def test_drops_routing_entries_pointing_at_deleted_rows(self, db):
|
||||
"""A routing entry that outlived its target would leave the gateway
|
||||
resuming a session id that no longer exists."""
|
||||
_insert(db, "junk", age_days=45)
|
||||
_insert(db, "keeper", age_days=1)
|
||||
db.save_gateway_routing_entry(
|
||||
"agent:main:telegram:dm:junk",
|
||||
json.dumps({"session_id": "junk"}),
|
||||
scope="/tmp/pytest-of-dev/test0",
|
||||
)
|
||||
db.save_gateway_routing_entry(
|
||||
"agent:main:telegram:dm:keeper",
|
||||
json.dumps({"session_id": "keeper"}),
|
||||
scope="/home/dev/project",
|
||||
)
|
||||
|
||||
deleted, routing_deleted = db.prune_never_active_keyed_sessions(
|
||||
older_than_days=30
|
||||
)
|
||||
|
||||
assert (deleted, routing_deleted) == (1, 1)
|
||||
remaining = db._conn.execute(
|
||||
"SELECT session_key FROM gateway_routing"
|
||||
).fetchall()
|
||||
assert [r[0] for r in remaining] == ["agent:main:telegram:dm:keeper"]
|
||||
|
||||
def test_no_candidates_is_a_no_op(self, db):
|
||||
_insert(db, "keeper", age_days=1)
|
||||
assert db.prune_never_active_keyed_sessions(older_than_days=30) == (0, 0)
|
||||
assert db._conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 1
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Repair of gateway sessions that lost their routing identity (#82616).
|
||||
|
||||
Incident shape (production, Aug 2026): a state.db write-path failure left the
|
||||
live Telegram DM in a session row that never received its identity columns.
|
||||
That row is invisible to ``find_latest_gateway_session_for_peer`` — both of
|
||||
its queries require the very columns the row lacks — so after a gateway
|
||||
restart the chat resolved to a keyed sibling three days older and the
|
||||
conversation time-travelled. The real messages were never lost, only
|
||||
unreachable.
|
||||
|
||||
These tests cover the offline repair path: detection must name the
|
||||
predecessor only when the evidence is unambiguous, and adoption must make
|
||||
the repaired row win recovery from then on.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
PEER = {
|
||||
"source": "telegram",
|
||||
"user_id": "6308981865",
|
||||
"session_key": "agent:main:telegram:dm:6308981865",
|
||||
"chat_id": "6308981865",
|
||||
"chat_type": "dm",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
store = SessionDB(db_path=tmp_path / "state.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def _mk_session(
|
||||
db,
|
||||
session_id,
|
||||
*,
|
||||
keyed=True,
|
||||
source="telegram",
|
||||
user_id=PEER["user_id"],
|
||||
started_at=None,
|
||||
last_activity_at=None,
|
||||
ended_at=None,
|
||||
end_reason=None,
|
||||
parent_session_id=None,
|
||||
model_config=None,
|
||||
messages=0,
|
||||
):
|
||||
kwargs = {"user_id": user_id}
|
||||
if keyed:
|
||||
kwargs.update(
|
||||
session_key=PEER["session_key"],
|
||||
chat_id=PEER["chat_id"],
|
||||
chat_type=PEER["chat_type"],
|
||||
)
|
||||
if parent_session_id:
|
||||
kwargs["parent_session_id"] = parent_session_id
|
||||
if model_config:
|
||||
kwargs["model_config"] = model_config
|
||||
db.create_session(session_id, source, **kwargs)
|
||||
for i in range(messages):
|
||||
db.append_message(
|
||||
session_id, "user" if i % 2 == 0 else "assistant", f"m{i}"
|
||||
)
|
||||
with db._lock:
|
||||
if keyed:
|
||||
# ``create_session`` on main does not carry presentation/origin
|
||||
# metadata into the INSERT — set it the way the gateway's
|
||||
# per-turn peer refresh does, so the donor row is realistic.
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET origin_json = ?, display_name = ? "
|
||||
"WHERE id = ?",
|
||||
(
|
||||
'{"platform": "telegram", "chat_id": "6308981865"}',
|
||||
"Teknium",
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
if started_at is not None:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(started_at, session_id),
|
||||
)
|
||||
if messages:
|
||||
# Message timestamps feed the recency expression; pin them to the
|
||||
# session window so tests control contiguity deterministically.
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp = ? WHERE session_id = ?",
|
||||
(last_activity_at or started_at, session_id),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET last_activity_at = ?, ended_at = ?, "
|
||||
"end_reason = ? WHERE id = ?",
|
||||
(last_activity_at, ended_at, end_reason, session_id),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def _incident(db, *, parent_link=False):
|
||||
"""Build the reported incident: keyed stale row + unkeyed live row."""
|
||||
now = time.time()
|
||||
stale_last = now - 3 * 86400
|
||||
_mk_session(
|
||||
db,
|
||||
"20260803_120103_37976afb",
|
||||
keyed=True,
|
||||
started_at=now - 6 * 86400,
|
||||
last_activity_at=stale_last,
|
||||
end_reason="agent_close",
|
||||
messages=4,
|
||||
)
|
||||
_mk_session(
|
||||
db,
|
||||
"20260806_161836_04c1d6c6",
|
||||
keyed=False,
|
||||
started_at=stale_last + 60,
|
||||
last_activity_at=now - 3600,
|
||||
parent_session_id=(
|
||||
"20260803_120103_37976afb" if parent_link else None
|
||||
),
|
||||
messages=6,
|
||||
)
|
||||
return "20260803_120103_37976afb", "20260806_161836_04c1d6c6"
|
||||
|
||||
|
||||
def _last_activity(db, session_id):
|
||||
with db._lock:
|
||||
row = db._conn.execute(
|
||||
"SELECT last_activity_at FROM sessions WHERE id = ?", (session_id,)
|
||||
).fetchone()
|
||||
return row["last_activity_at"]
|
||||
|
||||
|
||||
class TestDetection:
|
||||
def test_incident_orphan_is_adoptable_by_contiguity(self, db):
|
||||
stale, orphan = _incident(db)
|
||||
records = db.find_orphaned_gateway_sessions()
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record["orphan_id"] == orphan
|
||||
assert record["donor_id"] == stale
|
||||
assert record["session_key"] == PEER["session_key"]
|
||||
assert record["evidence"] == "contiguity"
|
||||
assert record["adoptable"] is True
|
||||
assert record["message_count"] == 6
|
||||
|
||||
def test_parent_link_is_used_without_a_time_window(self, db):
|
||||
stale, orphan = _incident(db, parent_link=True)
|
||||
# Push the predecessor far outside the contiguity window: a recorded
|
||||
# lineage is a fact, not a guess, so it must still resolve.
|
||||
long_ago = time.time() - 400 * 86400
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET last_activity_at = ? WHERE id = ?",
|
||||
(long_ago, stale),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp = ? WHERE session_id = ?",
|
||||
(long_ago, stale),
|
||||
)
|
||||
db._conn.commit()
|
||||
record = db.find_orphaned_gateway_sessions()[0]
|
||||
assert record["orphan_id"] == orphan
|
||||
assert record["donor_id"] == stale
|
||||
assert record["evidence"] == "lineage"
|
||||
assert record["adoptable"] is True
|
||||
|
||||
def test_sessions_without_messages_are_not_reported(self, db):
|
||||
_mk_session(db, "empty", keyed=False, started_at=time.time())
|
||||
assert db.find_orphaned_gateway_sessions() == []
|
||||
|
||||
def test_two_predecessors_in_the_window_fail_closed(self, db):
|
||||
stale, _ = _incident(db)
|
||||
quiet_at = _last_activity(db, stale)
|
||||
# A second chat on the same platform fell quiet at the same moment.
|
||||
_mk_session(
|
||||
db,
|
||||
"20260803_120104_other",
|
||||
keyed=True,
|
||||
user_id=None,
|
||||
started_at=quiet_at - 3600,
|
||||
last_activity_at=quiet_at,
|
||||
end_reason="agent_close",
|
||||
messages=2,
|
||||
)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET session_key = ? WHERE id = ?",
|
||||
("agent:main:telegram:dm:999", "20260803_120104_other"),
|
||||
)
|
||||
db._conn.commit()
|
||||
record = db.find_orphaned_gateway_sessions()[0]
|
||||
assert record["adoptable"] is False
|
||||
assert "ambiguous" in record["reason"]
|
||||
|
||||
def test_two_orphans_claiming_one_predecessor_fail_closed(self, db):
|
||||
stale, _ = _incident(db)
|
||||
_mk_session(
|
||||
db,
|
||||
"20260806_161840_second",
|
||||
keyed=False,
|
||||
started_at=_last_activity(db, stale) + 90,
|
||||
last_activity_at=time.time() - 3600,
|
||||
messages=3,
|
||||
)
|
||||
records = db.find_orphaned_gateway_sessions()
|
||||
assert len(records) == 2
|
||||
assert not any(r["adoptable"] for r in records)
|
||||
assert all("ambiguous" in r["reason"] for r in records)
|
||||
|
||||
def test_delegate_children_are_not_orphans(self, db):
|
||||
stale, orphan = _incident(db)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ?", (orphan,)
|
||||
)
|
||||
db._conn.execute("DELETE FROM sessions WHERE id = ?", (orphan,))
|
||||
db._conn.commit()
|
||||
_mk_session(
|
||||
db,
|
||||
"20260806_161836_delegate",
|
||||
keyed=False,
|
||||
started_at=_last_activity(db, stale) + 60,
|
||||
last_activity_at=time.time() - 3600,
|
||||
model_config={"_delegate_from": stale},
|
||||
messages=3,
|
||||
)
|
||||
assert db.find_orphaned_gateway_sessions() == []
|
||||
|
||||
def test_a_predecessor_of_another_platform_is_not_a_donor(self, db):
|
||||
stale, _ = _incident(db)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET source = 'discord' WHERE id = ?", (stale,)
|
||||
)
|
||||
db._conn.commit()
|
||||
record = db.find_orphaned_gateway_sessions()[0]
|
||||
assert record["adoptable"] is False
|
||||
assert record["donor_id"] is None
|
||||
|
||||
|
||||
class TestAdoption:
|
||||
def _resolve(self, db):
|
||||
return db.find_latest_gateway_session_for_peer(
|
||||
source=PEER["source"],
|
||||
user_id=PEER["user_id"],
|
||||
session_key=PEER["session_key"],
|
||||
chat_id=PEER["chat_id"],
|
||||
chat_type=PEER["chat_type"],
|
||||
)
|
||||
|
||||
def test_adoption_moves_recovery_to_the_live_conversation(self, db):
|
||||
stale, orphan = _incident(db)
|
||||
# Before: recovery hands the chat to the three-day-old row.
|
||||
assert self._resolve(db)["id"] == stale
|
||||
|
||||
assert db.adopt_orphaned_gateway_session(orphan, stale) is True
|
||||
|
||||
after = self._resolve(db)
|
||||
assert after["id"] == orphan
|
||||
assert after["chat_id"] == PEER["chat_id"]
|
||||
assert after["origin_json"]
|
||||
assert after["parent_session_id"] == stale
|
||||
|
||||
def test_predecessor_is_retired_under_a_non_resumable_reason(self, db):
|
||||
stale, orphan = _incident(db)
|
||||
db.adopt_orphaned_gateway_session(orphan, stale)
|
||||
row = db.get_session(stale)
|
||||
assert row["end_reason"] == "superseded_by_repair"
|
||||
assert row["ended_at"] is not None
|
||||
|
||||
def test_adoption_is_idempotent(self, db):
|
||||
stale, orphan = _incident(db)
|
||||
assert db.adopt_orphaned_gateway_session(orphan, stale) is True
|
||||
# The orphan is keyed now, so a replay must not re-retire anything.
|
||||
assert db.adopt_orphaned_gateway_session(orphan, stale) is False
|
||||
assert db.find_orphaned_gateway_sessions() == []
|
||||
|
||||
def test_existing_columns_are_never_overwritten(self, db):
|
||||
stale, orphan = _incident(db)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET display_name = ? WHERE id = ?",
|
||||
("Renamed", orphan),
|
||||
)
|
||||
db._conn.commit()
|
||||
db.adopt_orphaned_gateway_session(orphan, stale)
|
||||
assert db.get_session(orphan)["display_name"] == "Renamed"
|
||||
|
||||
def test_cross_source_adoption_is_refused(self, db):
|
||||
stale, orphan = _incident(db)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET source = 'discord' WHERE id = ?", (orphan,)
|
||||
)
|
||||
db._conn.commit()
|
||||
assert db.adopt_orphaned_gateway_session(orphan, stale) is False
|
||||
assert db.get_session(orphan)["session_key"] is None
|
||||
|
||||
def test_unkeyed_donor_is_refused(self, db):
|
||||
_, orphan = _incident(db)
|
||||
_mk_session(
|
||||
db, "no_key_donor", keyed=False, started_at=time.time() - 100
|
||||
)
|
||||
assert db.adopt_orphaned_gateway_session(orphan, "no_key_donor") is False
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Round-trip tests for the structured reasoning columns.
|
||||
|
||||
get_messages() returns reasoning_details / codex_reasoning_items /
|
||||
codex_message_items as the raw TEXT stored in their columns (it only
|
||||
hydrates content and tool_calls). Callers that feed those rows straight
|
||||
back into a write — the POST /api/sessions/{id}/fork handler pipes
|
||||
get_messages() into replace_messages() — must not re-encode that TEXT,
|
||||
or the forked session replays with reasoning fields decoding to strings
|
||||
and every isinstance(..., list) consumer silently drops them.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
REASONING_DETAILS = [
|
||||
{"type": "reasoning.text", "text": "compare both branches first", "format": "unknown"}
|
||||
]
|
||||
CODEX_REASONING_ITEMS = [
|
||||
{"id": "rs_1", "type": "reasoning", "encrypted_content": "opaque-blob"}
|
||||
]
|
||||
CODEX_MESSAGE_ITEMS = [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "done"}],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _seed(db, sid="src"):
|
||||
"""Session with one assistant message carrying all three reasoning fields."""
|
||||
db.create_session(sid, source="cli")
|
||||
db.append_message(sid, role="user", content="hi")
|
||||
db.append_message(
|
||||
sid,
|
||||
role="assistant",
|
||||
content="done",
|
||||
reasoning_details=REASONING_DETAILS,
|
||||
codex_reasoning_items=CODEX_REASONING_ITEMS,
|
||||
codex_message_items=CODEX_MESSAGE_ITEMS,
|
||||
)
|
||||
|
||||
|
||||
def _fork(db, src, dst):
|
||||
"""The fork handler's copy step: raw get_messages rows into replace_messages."""
|
||||
db.create_session(dst, source="cli")
|
||||
db.replace_messages(dst, db.get_messages(src))
|
||||
|
||||
|
||||
def _assistant(conversation):
|
||||
return next(m for m in conversation if m["role"] == "assistant")
|
||||
|
||||
|
||||
class TestDirectWrite:
|
||||
"""Live-runtime path: structured values in, structured values back."""
|
||||
|
||||
def test_reasoning_fields_hydrate_as_structures(self, db):
|
||||
_seed(db)
|
||||
msg = _assistant(db.get_messages_as_conversation("src"))
|
||||
assert msg["reasoning_details"] == REASONING_DETAILS
|
||||
assert msg["codex_reasoning_items"] == CODEX_REASONING_ITEMS
|
||||
assert msg["codex_message_items"] == CODEX_MESSAGE_ITEMS
|
||||
|
||||
|
||||
class TestForkRoundTrip:
|
||||
"""get_messages -> replace_messages must keep the stored TEXT intact."""
|
||||
|
||||
def test_reasoning_details_survive_fork(self, db):
|
||||
_seed(db)
|
||||
_fork(db, "src", "fork")
|
||||
msg = _assistant(db.get_messages_as_conversation("fork"))
|
||||
assert msg["reasoning_details"] == REASONING_DETAILS
|
||||
|
||||
def test_codex_reasoning_items_survive_fork(self, db):
|
||||
_seed(db)
|
||||
_fork(db, "src", "fork")
|
||||
msg = _assistant(db.get_messages_as_conversation("fork"))
|
||||
assert msg["codex_reasoning_items"] == CODEX_REASONING_ITEMS
|
||||
|
||||
def test_codex_message_items_survive_fork(self, db):
|
||||
_seed(db)
|
||||
_fork(db, "src", "fork")
|
||||
msg = _assistant(db.get_messages_as_conversation("fork"))
|
||||
assert msg["codex_message_items"] == CODEX_MESSAGE_ITEMS
|
||||
|
||||
def test_fork_of_fork_stays_stable(self, db):
|
||||
# Each extra round-trip used to add another encoding layer.
|
||||
_seed(db)
|
||||
_fork(db, "src", "fork1")
|
||||
_fork(db, "fork1", "fork2")
|
||||
msg = _assistant(db.get_messages_as_conversation("fork2"))
|
||||
assert msg["reasoning_details"] == REASONING_DETAILS
|
||||
assert msg["codex_reasoning_items"] == CODEX_REASONING_ITEMS
|
||||
assert msg["codex_message_items"] == CODEX_MESSAGE_ITEMS
|
||||
|
||||
|
||||
class TestAppendMessageRoundTrip:
|
||||
"""append_message accepts a stored row's already-serialized TEXT too."""
|
||||
|
||||
def test_string_value_not_double_encoded(self, db):
|
||||
_seed(db)
|
||||
row = next(m for m in db.get_messages("src") if m["role"] == "assistant")
|
||||
db.create_session("copy", source="cli")
|
||||
db.append_message(
|
||||
"copy",
|
||||
role="assistant",
|
||||
content="done",
|
||||
reasoning_details=row["reasoning_details"],
|
||||
)
|
||||
msg = _assistant(db.get_messages_as_conversation("copy"))
|
||||
assert msg["reasoning_details"] == REASONING_DETAILS
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Sibling-site regression tests for the #80216 bug class.
|
||||
|
||||
#80216 fixed /retry destroying soft-archived (``active=0/compacted=1``)
|
||||
in-place-compaction rows because ``rewrite_transcript`` defaulted to a
|
||||
destructive full ``replace_messages``. The same class existed at two more
|
||||
sites, fixed here:
|
||||
|
||||
- ``acp_adapter/session.py`` ``_persist`` (non-owned-agent branch): probed
|
||||
``has_archived_messages`` and FAILED OPEN into the destructive replace on
|
||||
any probe error (and could race a concurrent ``archive_and_compact``).
|
||||
Now passes ``active_only=True`` unconditionally.
|
||||
- ``tui_gateway/methods_prompt.py`` edit/regenerate truncation: bare
|
||||
``replace_messages`` deleted the archived transcript on every
|
||||
edit/regenerate of a compacted session. Now ``active_only=True``.
|
||||
|
||||
Behavior contract on a fresh (never-compacted) session: every row is
|
||||
``active=1``, so the active-only replace is identical to the full replace —
|
||||
also pinned below.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_db(tmp_path):
|
||||
"""A real SessionDB on a temp state.db."""
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _seed_compacted_session(db, session_id: str) -> None:
|
||||
"""Create a session with archived (active=0/compacted=1) + live rows."""
|
||||
db.create_session(session_id, "test")
|
||||
msgs = [
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "another old question"},
|
||||
{"role": "assistant", "content": "another old answer"},
|
||||
]
|
||||
db.append_messages_batch(session_id, msgs)
|
||||
db.archive_and_compact(
|
||||
session_id,
|
||||
[
|
||||
{"role": "assistant", "content": "summary of old turns"},
|
||||
{"role": "user", "content": "live question"},
|
||||
{"role": "assistant", "content": "live answer"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _archived_count(db, session_id: str) -> int:
|
||||
return sum(
|
||||
1
|
||||
for m in db.get_messages(session_id, include_inactive=True)
|
||||
if not m["active"]
|
||||
)
|
||||
|
||||
|
||||
class TestAcpPersistPreservesArchives:
|
||||
def test_persist_nonowned_branch_keeps_archived_rows(self, state_db):
|
||||
"""The ACP _persist fallback replace must not delete archived rows."""
|
||||
sid = "acp-compacted"
|
||||
_seed_compacted_session(state_db, sid)
|
||||
assert _archived_count(state_db, sid) == 4
|
||||
|
||||
# Drive the exact replace the non-owned-agent branch of _persist now
|
||||
# performs (active_only=True unconditionally, no probe).
|
||||
new_history = [
|
||||
{"role": "user", "content": "rewritten"},
|
||||
{"role": "assistant", "content": "rewritten answer"},
|
||||
]
|
||||
state_db.replace_messages(sid, new_history, active_only=True)
|
||||
|
||||
assert _archived_count(state_db, sid) == 4
|
||||
live = [
|
||||
m for m in state_db.get_messages_as_conversation(sid)
|
||||
if m.get("role") in ("user", "assistant")
|
||||
]
|
||||
assert [m["content"] for m in live] == ["rewritten", "rewritten answer"]
|
||||
|
||||
def test_persist_source_has_no_failopen_probe(self):
|
||||
"""The fail-open has_archived_messages probe must stay dead in _persist.
|
||||
|
||||
Guards the #80216 bug class at the source level: a probe that fails
|
||||
open (``except: has_archived = False``) silently reintroduces the
|
||||
destructive replace. ``active_only=True`` must be unconditional.
|
||||
"""
|
||||
import inspect
|
||||
import acp_adapter.session as acp_session
|
||||
|
||||
src = inspect.getsource(acp_session.SessionManager._persist)
|
||||
# Assert on the CALL, not a local-variable name — a reintroduced probe
|
||||
# under any local name (archived = db.has_archived_messages(...))
|
||||
# must still trip this guard. The explanatory comment in _persist
|
||||
# writes the name as "(has_archived_messages)" (paren BEFORE the
|
||||
# name), so the call-shaped substring doesn't false-positive on it.
|
||||
assert "has_archived_messages(" not in src, (
|
||||
"_persist re-grew a has_archived_messages probe — #80216 class"
|
||||
)
|
||||
assert "active_only=True" in src
|
||||
|
||||
def test_fresh_session_active_only_equals_full_replace(self, state_db):
|
||||
"""On a never-compacted session active_only=True must behave exactly
|
||||
like the historical full replace (the safety claim the unconditional
|
||||
switch rests on)."""
|
||||
sid = "acp-fresh"
|
||||
state_db.create_session(sid, "test")
|
||||
state_db.append_messages_batch(
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "a"},
|
||||
],
|
||||
)
|
||||
state_db.replace_messages(
|
||||
sid, [{"role": "user", "content": "only"}], active_only=True
|
||||
)
|
||||
rows = [
|
||||
m for m in state_db.get_messages_as_conversation(sid)
|
||||
if m.get("role") in ("user", "assistant")
|
||||
]
|
||||
assert [m["content"] for m in rows] == ["only"]
|
||||
assert _archived_count(state_db, sid) == 0
|
||||
|
||||
|
||||
class TestTuiPromptTruncationPreservesArchives:
|
||||
def test_truncation_source_uses_active_only(self):
|
||||
"""The edit/regenerate persistence call must pass active_only=True."""
|
||||
import inspect
|
||||
import tui_gateway.methods_prompt as mp
|
||||
|
||||
src = inspect.getsource(mp)
|
||||
# The truncation write is the only replace_messages call in the module;
|
||||
# it must carry active_only=True.
|
||||
import re
|
||||
calls = re.findall(r"db\.replace_messages\([^)]*\)", src, re.S)
|
||||
assert calls, "expected the truncation replace_messages call"
|
||||
for call in calls:
|
||||
assert "active_only=True" in call, (
|
||||
f"bare replace_messages in methods_prompt — #80216 class: {call}"
|
||||
)
|
||||
|
||||
def test_truncation_write_keeps_archived_rows(self, state_db):
|
||||
"""Drive the exact write shape methods_prompt now performs against a
|
||||
compacted session and assert the archive survives."""
|
||||
sid = "tui-compacted"
|
||||
_seed_compacted_session(state_db, sid)
|
||||
assert _archived_count(state_db, sid) == 4
|
||||
|
||||
truncated = [{"role": "user", "content": "kept head"}]
|
||||
state_db.replace_messages(sid, truncated, active_only=True)
|
||||
|
||||
assert _archived_count(state_db, sid) == 4
|
||||
live = [
|
||||
m for m in state_db.get_messages_as_conversation(sid)
|
||||
if m.get("role") in ("user", "assistant")
|
||||
]
|
||||
assert [m["content"] for m in live] == ["kept head"]
|
||||
|
||||
|
||||
class TestArchiveDroppedIsRecoverable:
|
||||
"""`active_only=True` protects rows archived EARLIER; it still DELETEs the
|
||||
live ones it replaces.
|
||||
|
||||
That is the last write standing between a mis-aimed rewind and permanent
|
||||
loss, and all three reported incidents (#70516, #80763, #82756) ended
|
||||
there with an empty WAL, no `active=0` rows and an FTS entry dropped in
|
||||
sync. `archive_dropped=True` keeps the replaced turns on disk under the
|
||||
same "the user took it back" marking `rewind_to_message` uses.
|
||||
"""
|
||||
|
||||
def test_dropped_turns_survive_as_inactive_rows(self, state_db):
|
||||
sid = "archive-dropped"
|
||||
state_db.create_session(sid, "test")
|
||||
state_db.append_messages_batch(
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "first reply"},
|
||||
{"role": "user", "content": "second"},
|
||||
{"role": "assistant", "content": "second reply"},
|
||||
],
|
||||
)
|
||||
|
||||
state_db.replace_messages(
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "first reply"},
|
||||
],
|
||||
active_only=True,
|
||||
archive_dropped=True,
|
||||
)
|
||||
|
||||
# The live transcript is exactly what a destructive replace would leave.
|
||||
live = [
|
||||
m for m in state_db.get_messages_as_conversation(sid)
|
||||
if m.get("role") in ("user", "assistant")
|
||||
]
|
||||
assert [m["content"] for m in live] == ["first", "first reply"]
|
||||
|
||||
# …but the dropped turns are still readable instead of gone.
|
||||
recovered = [
|
||||
m["content"]
|
||||
for m in state_db.get_messages(sid, include_inactive=True)
|
||||
if not m["active"]
|
||||
]
|
||||
assert "second" in recovered
|
||||
assert "second reply" in recovered
|
||||
|
||||
def test_archived_rows_use_rewind_marking_not_compaction(self, state_db):
|
||||
"""compacted=0 keeps abandoned turns out of session search results.
|
||||
|
||||
`archive_and_compact` marks its rows compacted=1 precisely so they stay
|
||||
discoverable; a rewound turn is one the user took back, so it must
|
||||
carry the `rewind_to_message` marking instead.
|
||||
"""
|
||||
sid = "archive-marking"
|
||||
state_db.create_session(sid, "test")
|
||||
state_db.append_messages_batch(
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "keep"},
|
||||
{"role": "assistant", "content": "drop me"},
|
||||
],
|
||||
)
|
||||
|
||||
state_db.replace_messages(
|
||||
sid,
|
||||
[{"role": "user", "content": "keep"}],
|
||||
active_only=True,
|
||||
archive_dropped=True,
|
||||
)
|
||||
|
||||
archived = [
|
||||
m for m in state_db.get_messages(sid, include_inactive=True)
|
||||
if not m["active"]
|
||||
]
|
||||
assert archived, "the replaced rows must still be on disk"
|
||||
assert all(not m.get("compacted") for m in archived)
|
||||
|
||||
def test_default_stays_destructive(self, state_db):
|
||||
"""The other three callers must be untouched by the new parameter."""
|
||||
sid = "archive-default"
|
||||
state_db.create_session(sid, "test")
|
||||
state_db.append_messages_batch(
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "gone"},
|
||||
{"role": "assistant", "content": "also gone"},
|
||||
],
|
||||
)
|
||||
|
||||
state_db.replace_messages(sid, [{"role": "user", "content": "fresh"}])
|
||||
|
||||
assert not [
|
||||
m for m in state_db.get_messages(sid, include_inactive=True)
|
||||
if not m["active"]
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Regression guard for #15000: --resume <id> after compression loses messages.
|
||||
|
||||
Context compression ends the current session and forks a new child session
|
||||
(linked by ``parent_session_id``). The SQLite flush cursor is reset, so
|
||||
only the latest descendant ends up with rows in the ``messages`` table —
|
||||
the parent row has ``message_count = 0``. ``hermes --resume <parent_id>``
|
||||
used to load zero rows and show a blank chat.
|
||||
|
||||
``SessionDB.resolve_resume_session_id()`` walks the parent → child chain
|
||||
and redirects to the first descendant that actually has messages. These
|
||||
tests pin that behaviour.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _make_chain(db: SessionDB, ids_with_parent):
|
||||
"""Create sessions in order, forcing started_at so ordering is deterministic."""
|
||||
base = int(time.time()) - 10_000
|
||||
for i, (sid, parent) in enumerate(ids_with_parent):
|
||||
db.create_session(sid, source="cli", parent_session_id=parent)
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(base + i * 100, sid),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_returns_self_when_only_parent_has_messages(db):
|
||||
# When a session already has messages AND no descendant has messages,
|
||||
# it should still be returned. The chain walk finds no better candidate.
|
||||
_make_chain(db, [("root", None), ("child", "root")])
|
||||
db.append_message("root", role="user", content="hi")
|
||||
assert db.resolve_resume_session_id("root") == "root"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_walks_from_middle_of_chain(db):
|
||||
# If the user happens to know an intermediate ID, we still find the msg-bearing descendant.
|
||||
_make_chain(db, [("a", None), ("b", "a"), ("c", "b"), ("d", "c")])
|
||||
db.append_message("d", role="user", content="x")
|
||||
assert db.resolve_resume_session_id("b") == "d"
|
||||
assert db.resolve_resume_session_id("c") == "d"
|
||||
|
||||
|
||||
def test_follows_compression_tip_when_parent_retains_messages(db):
|
||||
# The bug behind the desktop "I came back and the reply isn't there" report
|
||||
# on large sessions: auto-compression ends the live session and forks a
|
||||
# continuation child, but a long parent keeps its own flushed message rows.
|
||||
# The empty-head walk below never redirects a non-empty head, so resuming
|
||||
# the parent id reloaded the pre-compression transcript and the response
|
||||
# generated *after* compression (which lives in the continuation) was
|
||||
# missing. resolve_resume_session_id must follow the compression-tip chain
|
||||
# forward even when the parent still has messages.
|
||||
base = int(time.time()) - 10_000
|
||||
db.create_session("root", source="cli")
|
||||
db.append_message("root", role="user", content="pre-compression turn")
|
||||
db.end_session("root", "compression")
|
||||
db.create_session("cont", source="cli", parent_session_id="root")
|
||||
db.append_message("cont", role="assistant", content="post-compression reply")
|
||||
# Force deterministic ordering so the continuation's started_at is clearly
|
||||
# at/after the parent's ended_at (the get_compression_tip discriminator).
|
||||
conn = db._conn
|
||||
assert conn is not None
|
||||
conn.execute("UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'root'", (base, base + 50))
|
||||
conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont'", (base + 100,))
|
||||
conn.commit()
|
||||
|
||||
assert db.resolve_resume_session_id("root") == "cont"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_prefers_most_recent_child_when_fork_exists(db):
|
||||
# If a session was somehow forked (two children), pick the latest one.
|
||||
# In practice, compression only produces single-chain shape, but the helper
|
||||
# should degrade gracefully.
|
||||
_make_chain(db, [
|
||||
("parent", None),
|
||||
("older_fork", "parent"),
|
||||
("newer_fork", "parent"),
|
||||
])
|
||||
db.append_message("newer_fork", role="user", content="x")
|
||||
assert db.resolve_resume_session_id("parent") == "newer_fork"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""get_messages_as_conversation(repair_alternation=True) — heal durable
|
||||
alternation violations at the restore boundary.
|
||||
|
||||
A turn that persists a user row but no assistant row (e.g. its reply was
|
||||
suppressed, or two concurrent turns interleaved their flushes) leaves a
|
||||
``user;user`` pair in state.db. Without repair at restore, the defensive
|
||||
pre-request ``repair_message_sequence`` re-fires on EVERY request for the
|
||||
rest of the session's life, because it mutates only the per-request list.
|
||||
|
||||
Default (``repair_alternation=False``) must stay verbatim: inspection and
|
||||
export consumers (trace upload, context guard) read the transcript as-is.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
db_path = tmp_path / "test_state.db"
|
||||
session_db = SessionDB(db_path=db_path)
|
||||
yield session_db
|
||||
session_db.close()
|
||||
|
||||
|
||||
def _seed_wedged_session(db, session_id="s1"):
|
||||
"""assistant → user → user (no assistant row between): the durable wedge."""
|
||||
db.create_session(session_id, "system prompt")
|
||||
db.append_message(session_id=session_id, role="user", content="first ask")
|
||||
db.append_message(session_id=session_id, role="assistant", content="first reply")
|
||||
db.append_message(session_id=session_id, role="user", content="unanswered turn")
|
||||
db.append_message(session_id=session_id, role="user", content="next turn")
|
||||
db.append_message(session_id=session_id, role="assistant", content="next reply")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_repair_alternation_merges_user_pair(db):
|
||||
_seed_wedged_session(db)
|
||||
messages = db.get_messages_as_conversation("s1", repair_alternation=True)
|
||||
roles = [m["role"] for m in messages]
|
||||
assert roles == ["user", "assistant", "user", "assistant"]
|
||||
# Both user texts survive, merged in order — no user input is lost.
|
||||
merged = messages[2]["content"]
|
||||
assert "unanswered turn" in merged and "next turn" in merged
|
||||
assert merged.index("unanswered turn") < merged.index("next turn")
|
||||
|
||||
|
||||
def test_repaired_load_is_stable_under_prerequest_repair(db):
|
||||
"""The restored list must yield ZERO further repairs — this is the whole
|
||||
point: the pre-request defensive repair stops firing every turn."""
|
||||
from agent.agent_runtime_helpers import repair_message_sequence
|
||||
|
||||
_seed_wedged_session(db)
|
||||
messages = db.get_messages_as_conversation("s1", repair_alternation=True)
|
||||
assert repair_message_sequence(None, messages) == 0
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The live-replay restore SITES must pass repair_alternation=True. The initial
|
||||
# fix covered gateway load_transcript + CLI startup resume; these are the other
|
||||
# live-replay restore paths (ACP session resume, CLI /resume, TUI resume) that
|
||||
# hand the loaded transcript to a live agent for subsequent turns.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_wedged_acp_session(db, session_id="acp1"):
|
||||
db.create_session(session_id, "acp")
|
||||
db.append_message(session_id=session_id, role="user", content="first ask")
|
||||
db.append_message(session_id=session_id, role="assistant", content="first reply")
|
||||
db.append_message(session_id=session_id, role="user", content="unanswered turn")
|
||||
db.append_message(session_id=session_id, role="user", content="next turn")
|
||||
db.append_message(session_id=session_id, role="assistant", content="next reply")
|
||||
|
||||
|
||||
def test_acp_restore_heals_alternation_for_live_replay(db):
|
||||
"""acp_adapter.SessionManager._restore feeds LIVE REPLAY: the loaded history
|
||||
becomes the resumed agent's working conversation. It must be alternation-
|
||||
clean so the pre-request repair doesn't re-fire every turn."""
|
||||
from acp_adapter.session import SessionManager
|
||||
|
||||
_seed_wedged_acp_session(db, "acp1")
|
||||
|
||||
class _StubAgent:
|
||||
model = "stub"
|
||||
|
||||
mgr = SessionManager(agent_factory=lambda: _StubAgent(), db=db)
|
||||
state = mgr._restore("acp1")
|
||||
|
||||
assert state is not None
|
||||
roles = [m["role"] for m in state.history]
|
||||
# No consecutive user turns — the durable user;user wedge was healed.
|
||||
assert roles == ["user", "assistant", "user", "assistant"], roles
|
||||
for a, b in zip(roles, roles[1:]):
|
||||
assert not (a == "user" and b == "user"), "unhealed user;user in ACP live replay"
|
||||
# No user input lost — both user texts survive, merged in order.
|
||||
merged = state.history[2]["content"]
|
||||
assert "unanswered turn" in merged and "next turn" in merged
|
||||
@@ -0,0 +1,51 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
database = SessionDB(tmp_path / "state.db")
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def _compression_pair(db: SessionDB):
|
||||
base = time.time() - 100
|
||||
db.create_session("root", source="cli")
|
||||
db.create_session("tip", source="cli", parent_session_id="root")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'",
|
||||
(base, base + 10),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'",
|
||||
(base + 20,),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_archiving_compression_tip_archives_projected_root(db):
|
||||
_compression_pair(db)
|
||||
|
||||
assert db.set_session_archived("tip", True) is True
|
||||
|
||||
assert db.get_session("root")["archived"] == 1
|
||||
assert db.get_session("tip")["archived"] == 1
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == []
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True, archived_only=True)] == ["tip"]
|
||||
|
||||
|
||||
def test_unarchiving_compression_tip_unarchives_projected_root(db):
|
||||
_compression_pair(db)
|
||||
db.set_session_archived("tip", True)
|
||||
|
||||
assert db.set_session_archived("tip", False) is True
|
||||
|
||||
assert db.get_session("root")["archived"] == 0
|
||||
assert db.get_session("tip")["archived"] == 0
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == ["tip"]
|
||||
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
database = SessionDB(tmp_path / "state.db")
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_hidden_excluded_by_default_included_on_request(db):
|
||||
db.create_session("visible", source="cli")
|
||||
db.create_session("secret", source="cli")
|
||||
# Give both a message so the default min_message_count filter keeps them.
|
||||
for sid in ("visible", "secret"):
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET message_count = 1 WHERE id = ?", (sid,)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
# Flip the hidden flag on one session.
|
||||
assert db.set_session_hidden("secret", True) is True
|
||||
assert db.get_session("secret")["hidden"] == 1
|
||||
assert db.get_session("visible")["hidden"] == 0
|
||||
|
||||
# Default listing drops the hidden row; include_hidden=True surfaces it.
|
||||
default_ids = {s["id"] for s in db.list_sessions_rich(min_message_count=1)}
|
||||
assert default_ids == {"visible"}
|
||||
|
||||
all_ids = {
|
||||
s["id"]
|
||||
for s in db.list_sessions_rich(min_message_count=1, include_hidden=True)
|
||||
}
|
||||
assert all_ids == {"visible", "secret"}
|
||||
|
||||
# Unhiding brings it back into the default listing.
|
||||
assert db.set_session_hidden("secret", False) is True
|
||||
assert db.get_session("secret")["hidden"] == 0
|
||||
unhidden_ids = {s["id"] for s in db.list_sessions_rich(min_message_count=1)}
|
||||
assert unhidden_ids == {"visible", "secret"}
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Lifecycle status classification for session pickers.
|
||||
|
||||
Covers ``classify_session_status`` (pure last-message shape → status) and
|
||||
``SessionDB.session_lifecycle_statuses`` (batched last-message lookup), plus
|
||||
the delete wiring the picker's 'd' key relies on.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import (
|
||||
SESSION_STATUS_COMPLETE,
|
||||
SESSION_STATUS_EMPTY,
|
||||
SESSION_STATUS_ERROR,
|
||||
SESSION_STATUS_INTERRUPTED,
|
||||
SessionDB,
|
||||
classify_session_status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
database = SessionDB(tmp_path / "state.db")
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure classifier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role,has_tool_calls,finish_reason,expected",
|
||||
[
|
||||
("assistant", False, "stop", SESSION_STATUS_COMPLETE),
|
||||
("assistant", False, None, SESSION_STATUS_COMPLETE),
|
||||
("assistant", False, "length", SESSION_STATUS_COMPLETE),
|
||||
("assistant", True, "tool_calls", SESSION_STATUS_INTERRUPTED),
|
||||
("user", False, None, SESSION_STATUS_INTERRUPTED),
|
||||
("tool", False, None, SESSION_STATUS_INTERRUPTED),
|
||||
("assistant", False, "error", SESSION_STATUS_ERROR),
|
||||
("assistant", True, "error", SESSION_STATUS_ERROR),
|
||||
("user", False, "agent_error", SESSION_STATUS_ERROR),
|
||||
("system", False, None, SESSION_STATUS_COMPLETE),
|
||||
(None, False, None, SESSION_STATUS_COMPLETE),
|
||||
],
|
||||
)
|
||||
def test_classify_session_status(role, has_tool_calls, finish_reason, expected):
|
||||
assert classify_session_status(role, has_tool_calls, finish_reason) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-backed batch classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_session_lifecycle_statuses_shapes(db):
|
||||
# complete: normal user → assistant exchange
|
||||
db.create_session("s_complete", source="cli")
|
||||
db.append_message("s_complete", "user", "hi")
|
||||
db.append_message("s_complete", "assistant", "hello", finish_reason="stop")
|
||||
|
||||
# interrupted: user asked, no reply landed
|
||||
db.create_session("s_user_tail", source="cli")
|
||||
db.append_message("s_user_tail", "user", "are you there?")
|
||||
|
||||
# interrupted: assistant fired tool calls, no tool result followed
|
||||
db.create_session("s_pending_tool", source="cli")
|
||||
db.append_message("s_pending_tool", "user", "run it")
|
||||
db.append_message(
|
||||
"s_pending_tool",
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[{"id": "c1", "function": {"name": "terminal", "arguments": "{}"}}],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
# complete: full tool round-trip then final assistant reply
|
||||
db.create_session("s_tool_roundtrip", source="cli")
|
||||
db.append_message("s_tool_roundtrip", "user", "run it")
|
||||
db.append_message(
|
||||
"s_tool_roundtrip",
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[{"id": "c1", "function": {"name": "terminal", "arguments": "{}"}}],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
db.append_message("s_tool_roundtrip", "tool", "ok", tool_call_id="c1")
|
||||
db.append_message("s_tool_roundtrip", "assistant", "done", finish_reason="stop")
|
||||
|
||||
# interrupted: tool result present but assistant never consumed it
|
||||
db.create_session("s_tool_tail", source="cli")
|
||||
db.append_message("s_tool_tail", "user", "run it")
|
||||
db.append_message(
|
||||
"s_tool_tail",
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[{"id": "c2", "function": {"name": "terminal", "arguments": "{}"}}],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
db.append_message("s_tool_tail", "tool", "ok", tool_call_id="c2")
|
||||
|
||||
# error: last message carries an error finish_reason
|
||||
db.create_session("s_error", source="cli")
|
||||
db.append_message("s_error", "user", "hi")
|
||||
db.append_message("s_error", "assistant", "boom", finish_reason="error")
|
||||
|
||||
# empty: session row exists, zero messages
|
||||
db.create_session("s_empty", source="cli")
|
||||
|
||||
statuses = db.session_lifecycle_statuses(
|
||||
[
|
||||
"s_complete",
|
||||
"s_user_tail",
|
||||
"s_pending_tool",
|
||||
"s_tool_roundtrip",
|
||||
"s_tool_tail",
|
||||
"s_error",
|
||||
"s_empty",
|
||||
]
|
||||
)
|
||||
assert statuses == {
|
||||
"s_complete": SESSION_STATUS_COMPLETE,
|
||||
"s_user_tail": SESSION_STATUS_INTERRUPTED,
|
||||
"s_pending_tool": SESSION_STATUS_INTERRUPTED,
|
||||
"s_tool_roundtrip": SESSION_STATUS_COMPLETE,
|
||||
"s_tool_tail": SESSION_STATUS_INTERRUPTED,
|
||||
"s_error": SESSION_STATUS_ERROR,
|
||||
"s_empty": SESSION_STATUS_EMPTY,
|
||||
}
|
||||
|
||||
|
||||
def test_session_lifecycle_statuses_empty_input(db):
|
||||
assert db.session_lifecycle_statuses([]) == {}
|
||||
assert db.session_lifecycle_statuses([None, ""]) == {}
|
||||
|
||||
|
||||
def test_session_lifecycle_statuses_unknown_id(db):
|
||||
# Unknown ids classify as 'empty' (no messages), never raise.
|
||||
assert db.session_lifecycle_statuses(["nope"]) == {
|
||||
"nope": SESSION_STATUS_EMPTY
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Picker helpers (status annotation + delete wiring)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_annotate_session_statuses(db):
|
||||
from hermes_cli.main import _annotate_session_statuses, _session_status_tag
|
||||
|
||||
db.create_session("s1", source="cli")
|
||||
db.append_message("s1", "user", "hi")
|
||||
db.append_message("s1", "assistant", "hello", finish_reason="stop")
|
||||
db.create_session("s2", source="cli")
|
||||
db.append_message("s2", "user", "hi")
|
||||
|
||||
rows = [{"id": "s1"}, {"id": "s2"}]
|
||||
_annotate_session_statuses(rows, db)
|
||||
assert rows[0]["_status"] == SESSION_STATUS_COMPLETE
|
||||
assert rows[1]["_status"] == SESSION_STATUS_INTERRUPTED
|
||||
|
||||
# No db → rows untouched, tag falls back to '-'
|
||||
bare = [{"id": "s1"}]
|
||||
_annotate_session_statuses(bare, None)
|
||||
assert "_status" not in bare[0]
|
||||
assert _session_status_tag(bare[0].get("_status")) == "-"
|
||||
|
||||
# Tag mapping
|
||||
assert _session_status_tag(SESSION_STATUS_COMPLETE) == "done"
|
||||
assert _session_status_tag(SESSION_STATUS_INTERRUPTED) == "intr"
|
||||
assert _session_status_tag(SESSION_STATUS_ERROR) == "err"
|
||||
assert _session_status_tag(SESSION_STATUS_EMPTY) == "empty"
|
||||
|
||||
|
||||
def test_delete_session_removes_session_and_messages(db, tmp_path):
|
||||
db.create_session("doomed", source="cli")
|
||||
db.append_message("doomed", "user", "hi")
|
||||
db.append_message("doomed", "assistant", "hello", finish_reason="stop")
|
||||
|
||||
assert db.delete_session("doomed", sessions_dir=tmp_path / "sessions") is True
|
||||
assert db.get_session("doomed") is None
|
||||
remaining = db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE session_id = ?", ("doomed",)
|
||||
).fetchone()[0]
|
||||
assert remaining == 0
|
||||
# Deleting again reports False (not found)
|
||||
assert db.delete_session("doomed") is False
|
||||
@@ -0,0 +1,95 @@
|
||||
import time
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
def test_export_candidates_via_prune_filters_ended_old_sessions(tmp_path, monkeypatch):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0)
|
||||
try:
|
||||
db.create_session("old_cli", source="cli")
|
||||
db.end_session("old_cli", "done")
|
||||
db._conn.execute("UPDATE sessions SET started_at=?, ended_at=? WHERE id=?", (1_000_000.0, 1_000_010.0, "old_cli"))
|
||||
|
||||
db.create_session("new_cli", source="cli")
|
||||
db.end_session("new_cli", "done")
|
||||
db._conn.execute("UPDATE sessions SET started_at=?, ended_at=? WHERE id=?", (1_990_000.0, 1_990_010.0, "new_cli"))
|
||||
|
||||
db.create_session("old_active", source="cli")
|
||||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (1_000_000.0, "old_active"))
|
||||
db._conn.commit()
|
||||
|
||||
# Export uses the shared prune/archive candidate selection.
|
||||
candidates = db.list_prune_candidates(
|
||||
started_before=2_000_000.0 - 5 * 86400, archived=None
|
||||
)
|
||||
assert [c["id"] for c in candidates] == ["old_cli"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
|
||||
def test_get_compression_lineage_returns_only_compression_chain(tmp_path):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
try:
|
||||
db.create_session("root", source="cli")
|
||||
db.end_session("root", "compression")
|
||||
db.create_session("child", source="cli", parent_session_id="root")
|
||||
db.end_session("child", "compression")
|
||||
db.create_session("tip", source="cli", parent_session_id="child")
|
||||
db.create_session("branch", source="cli", parent_session_id="root", model_config={"_branched_from": "root"})
|
||||
db.create_session("delegate", source="delegate", parent_session_id="child", model_config={"_delegate_from": "child"})
|
||||
db.create_session("tool", source="tool", parent_session_id="child")
|
||||
|
||||
assert db.get_compression_lineage("tip") == ["root", "child", "tip"]
|
||||
assert db.get_compression_lineage("branch") == ["branch"]
|
||||
assert db.get_compression_lineage("delegate") == ["delegate"]
|
||||
assert db.get_compression_lineage("tool") == ["tool"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_fork_children_created_before_continuation_do_not_hijack_lineage(tmp_path):
|
||||
# Regression: the forward walk used to accept any non-branch child as the
|
||||
# compression continuation. A delegate/tool child spawned BEFORE the real
|
||||
# continuation row (the common runtime ordering — the subagent exists
|
||||
# before compression rotates the session) was picked as the successor,
|
||||
# so lineage and session .md export followed the subagent's transcript.
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
try:
|
||||
db.create_session("root", source="cli")
|
||||
db.append_message("root", role="user", content="root msg")
|
||||
db.create_session(
|
||||
"delegate",
|
||||
source="delegate",
|
||||
parent_session_id="root",
|
||||
model_config={"_delegate_from": "root"},
|
||||
)
|
||||
db.append_message("delegate", role="user", content="delegate private msg")
|
||||
db.end_session("root", "compression")
|
||||
db.create_session("continuation", source="cli", parent_session_id="root")
|
||||
db.append_message("continuation", role="user", content="continuation msg")
|
||||
|
||||
db.create_session("root2", source="cli")
|
||||
db.create_session("toolchild", source="tool", parent_session_id="root2")
|
||||
db.end_session("root2", "compression")
|
||||
db.create_session("cont2", source="cli", parent_session_id="root2")
|
||||
|
||||
assert db.get_compression_lineage("root") == ["root", "continuation"]
|
||||
assert db.get_compression_lineage("continuation") == ["root", "continuation"]
|
||||
assert db.get_compression_lineage("root2") == ["root2", "cont2"]
|
||||
|
||||
exported = db.export_session_lineage("root")
|
||||
assert exported is not None
|
||||
assert exported["lineage_session_ids"] == ["root", "continuation"]
|
||||
contents = [
|
||||
m.get("content")
|
||||
for seg in exported["segments"]
|
||||
for m in (seg.get("messages") or [])
|
||||
]
|
||||
assert contents == ["root msg", "continuation msg"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
database = SessionDB(tmp_path / "state.db")
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def _last_read(db, sid):
|
||||
row = db._conn.execute(
|
||||
"SELECT last_read_at FROM sessions WHERE id = ?", (sid,)
|
||||
).fetchone()
|
||||
return row["last_read_at"] if row is not None else None
|
||||
|
||||
|
||||
def _row(db, sid):
|
||||
rows = db.list_sessions_rich(include_archived=True)
|
||||
return next(s for s in rows if s["id"] == sid)
|
||||
|
||||
|
||||
def test_untracked_sessions_are_read(db):
|
||||
"""NULL watermark = never tracked = read, so shipping the column doesn't
|
||||
badge a user's entire pre-feature history at once."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(session_id="s1", role="user", content="hi")
|
||||
|
||||
assert _last_read(db, "s1") is None
|
||||
assert _row(db, "s1")["unread"] is False
|
||||
|
||||
|
||||
def test_mark_read_then_new_activity_flips_back_to_unread(db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(session_id="s1", role="user", content="hi")
|
||||
|
||||
assert db.set_session_read("s1") is True
|
||||
assert _row(db, "s1")["unread"] is False
|
||||
|
||||
# New activity postdating the watermark makes it unread again without
|
||||
# any write on the message path.
|
||||
time.sleep(0.01)
|
||||
db.append_message(session_id="s1", role="assistant", content="reply")
|
||||
assert _row(db, "s1")["unread"] is True
|
||||
|
||||
|
||||
def test_mark_unread_explicitly(db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(session_id="s1", role="user", content="hi")
|
||||
db.set_session_read("s1")
|
||||
|
||||
assert db.set_session_read("s1", read=False) is True
|
||||
assert _last_read(db, "s1") == 0.0
|
||||
assert _row(db, "s1")["unread"] is True
|
||||
|
||||
|
||||
def test_missing_session_returns_false(db):
|
||||
assert db.set_session_read("nope") is False
|
||||
|
||||
|
||||
def _compression_pair(db: SessionDB):
|
||||
base = time.time() - 100
|
||||
db.create_session("root", source="cli")
|
||||
db.create_session("tip", source="cli", parent_session_id="root")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'",
|
||||
(base, base + 10),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'",
|
||||
(base + 20,),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_reading_compression_tip_stamps_whole_lineage(db):
|
||||
_compression_pair(db)
|
||||
|
||||
assert db.set_session_read("tip") is True
|
||||
|
||||
root_read = _last_read(db, "root")
|
||||
assert root_read is not None and root_read > 0
|
||||
assert root_read == _last_read(db, "tip")
|
||||
|
||||
# The projected conversation row (root surfaced as tip) derives read.
|
||||
rows = db.list_sessions_rich(order_by_last_active=True)
|
||||
assert [s["id"] for s in rows] == ["tip"]
|
||||
assert rows[0]["unread"] is False
|
||||
|
||||
|
||||
def test_marking_root_unread_marks_projected_conversation(db):
|
||||
_compression_pair(db)
|
||||
db.set_session_read("tip")
|
||||
|
||||
assert db.set_session_read("root", read=False) is True
|
||||
|
||||
rows = db.list_sessions_rich(order_by_last_active=True)
|
||||
assert [s["id"] for s in rows] == ["tip"]
|
||||
assert rows[0]["unread"] is True
|
||||
@@ -0,0 +1,472 @@
|
||||
"""Shared SessionDB registry lifecycle regressions (#90837 review).
|
||||
|
||||
Covers the three ownership invariants the PR review demanded:
|
||||
|
||||
1. INODE REPLACEMENT — a generation with live holders must NEVER be
|
||||
closed by a third caller's acquire. Retire-and-drain, not
|
||||
revoke-by-pathname: existing holders keep a working handle, new
|
||||
callers get the fresh generation, and each generation's final
|
||||
release tears down exactly that generation.
|
||||
2. REPLACEMENT-OPEN FAILURE — if the fresh open fails after an inode
|
||||
change retired the old generation, the registry must hold NO entry
|
||||
for the path (never a closed stale object), and the next acquire
|
||||
retries fresh.
|
||||
3. CLOSE OUTSIDE THE LOCK — a final release's teardown must not run
|
||||
under the registry lock (it stops the token writer, checkpoints the
|
||||
WAL, drains the read pool — none of which may stall acquisition for
|
||||
every state.db in the process).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state_registry as registry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
"""Isolate the process-global registry between tests."""
|
||||
registry.close_all()
|
||||
registry._generations.clear()
|
||||
registry._retired.clear()
|
||||
registry._opening.clear()
|
||||
yield
|
||||
registry.close_all()
|
||||
registry._generations.clear()
|
||||
registry._retired.clear()
|
||||
registry._opening.clear()
|
||||
|
||||
|
||||
def _replace_file_preserving_schema(src: Path, dst: Path) -> None:
|
||||
"""Simulate snapshot-restore / recovery: new inode, same logical DB.
|
||||
|
||||
Copies the live DB to a temp name, removes the original, and renames
|
||||
the copy into place — the replacement has a different inode.
|
||||
"""
|
||||
tmp = dst.with_suffix(".replacement.tmp")
|
||||
shutil.copy2(dst, tmp)
|
||||
os.unlink(dst)
|
||||
os.rename(tmp, dst)
|
||||
|
||||
|
||||
class TestInodeReplacement:
|
||||
def test_live_holders_keep_working_handle_across_replacement(self, tmp_path):
|
||||
"""Two active refs → inode replacement → third caller gets NEW
|
||||
generation; the first two keep a working handle and their
|
||||
releases tear down only their own generation."""
|
||||
db_path = tmp_path / "state.db"
|
||||
|
||||
a = registry.acquire(db_path)
|
||||
b = registry.acquire(db_path)
|
||||
assert a is b
|
||||
|
||||
_replace_file_preserving_schema(db_path, db_path)
|
||||
|
||||
c = registry.acquire(db_path)
|
||||
assert c is not a, "new caller must get the fresh generation"
|
||||
|
||||
# A and B still hold the OLD generation — it must be alive, not
|
||||
# closed underneath them (the review's core blocker). The old
|
||||
# generation's own write path detects the replacement and fails
|
||||
# with the typed StateDbReplacedError (existing protection); the
|
||||
# registry's job is that the connection object stays VALID —
|
||||
# a catchable, typed error, never a use-after-close segfault or
|
||||
# "Cannot operate on a closed database".
|
||||
assert a._conn is not None, "retired generation closed while holders live"
|
||||
from hermes_state import StateDbReplacedError
|
||||
|
||||
with pytest.raises(StateDbReplacedError):
|
||||
a.create_session(
|
||||
session_id="old-gen-session",
|
||||
source="cli",
|
||||
model="m",
|
||||
model_config={},
|
||||
system_prompt=None,
|
||||
)
|
||||
|
||||
# New generation works independently.
|
||||
c.create_session(
|
||||
session_id="new-gen-session",
|
||||
source="cli",
|
||||
model="m",
|
||||
model_config={},
|
||||
system_prompt=None,
|
||||
)
|
||||
assert c.get_session("new-gen-session") is not None
|
||||
|
||||
# Releases route to the right generation: A and B release the
|
||||
# OLD one (object-keyed), C releases the NEW one.
|
||||
assert registry.release(a) is True
|
||||
assert a._conn is not None, "one holder releasing must not tear down the other"
|
||||
assert registry.release(b) is True
|
||||
assert a._conn is None, "final old-generation release tears it down"
|
||||
assert c._conn is not None, "old-generation teardown must not touch the new one"
|
||||
|
||||
assert registry.release(c) is True
|
||||
assert c._conn is None
|
||||
stats = registry.stats()
|
||||
assert stats["live_generations"] == 0
|
||||
assert stats["retired_generations"] == 0
|
||||
|
||||
def test_retired_generation_never_relent_even_after_drain(self, tmp_path):
|
||||
"""After replacement, repeated acquires all return the NEW
|
||||
generation — the retired one is never lent again, even while it
|
||||
still has live holders."""
|
||||
db_path = tmp_path / "state.db"
|
||||
first = registry.acquire(db_path)
|
||||
|
||||
_replace_file_preserving_schema(db_path, db_path)
|
||||
|
||||
second = registry.acquire(db_path)
|
||||
third = registry.acquire(db_path)
|
||||
assert second is third
|
||||
assert second is not first
|
||||
# Retired generation still drainable by its holder.
|
||||
assert registry.release(first) is True
|
||||
assert first._conn is None
|
||||
|
||||
def test_open_failure_after_replacement_leaves_no_stale_entry(self, tmp_path, monkeypatch):
|
||||
"""Replacement-open failure must not leave a closed stale object
|
||||
as the registry's authority for the path."""
|
||||
db_path = tmp_path / "state.db"
|
||||
old = registry.acquire(db_path)
|
||||
|
||||
_replace_file_preserving_schema(db_path, db_path)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fail_open(path):
|
||||
calls["n"] += 1
|
||||
raise OSError("disk temporarily gone")
|
||||
|
||||
monkeypatch.setattr(registry, "_open_session_db", _fail_open)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
registry.acquire(db_path)
|
||||
|
||||
# No live entry for the path — the next acquire retries fresh.
|
||||
assert db_path not in registry._generations
|
||||
assert stats_live_for(db_path) is None
|
||||
|
||||
monkeypatch.setattr(
|
||||
registry,
|
||||
"_open_session_db",
|
||||
lambda path: _make_session_db(path),
|
||||
)
|
||||
fresh = registry.acquire(db_path)
|
||||
assert fresh is not old
|
||||
assert fresh._conn is not None
|
||||
|
||||
# The old generation still drains correctly through its holder.
|
||||
assert registry.release(old) is True
|
||||
assert old._conn is None
|
||||
|
||||
|
||||
def _make_session_db(path):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
return SessionDB(db_path=Path(path))
|
||||
|
||||
|
||||
def stats_live_for(path: Path):
|
||||
generation = registry._generations.get(Path(path))
|
||||
return generation
|
||||
|
||||
|
||||
class TestTeardownOutsideLock:
|
||||
def test_concurrent_cold_acquire_opens_one_writer(self, tmp_path, monkeypatch):
|
||||
"""Concurrent first callers must not construct redundant writers.
|
||||
|
||||
Returning one winning object is not enough: every losing constructor
|
||||
has already opened its own writable SQLite connection by then. Hold
|
||||
the first construction so peer callers overlap deterministically and
|
||||
assert the registry single-flights the open itself.
|
||||
"""
|
||||
db_path = tmp_path / "state.db"
|
||||
callers = 6
|
||||
ready = threading.Barrier(callers + 1)
|
||||
release_open = threading.Event()
|
||||
count_lock = threading.Lock()
|
||||
open_calls = 0
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, path):
|
||||
self.db_path = path
|
||||
self._shared_registry_owned = False
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def _blocked_open(path):
|
||||
nonlocal open_calls
|
||||
with count_lock:
|
||||
open_calls += 1
|
||||
assert release_open.wait(5.0)
|
||||
return _FakeDB(path)
|
||||
|
||||
monkeypatch.setattr(registry, "_open_session_db", _blocked_open)
|
||||
|
||||
def _acquire():
|
||||
try:
|
||||
ready.wait()
|
||||
results.append(registry.acquire(db_path))
|
||||
except BaseException as exc: # pragma: no cover - failure path
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=_acquire) for _ in range(callers)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
ready.wait()
|
||||
time.sleep(0.1)
|
||||
release_open.set()
|
||||
for thread in threads:
|
||||
thread.join(10.0)
|
||||
assert not thread.is_alive(), "concurrent acquire deadlocked"
|
||||
|
||||
assert errors == []
|
||||
assert open_calls == 1
|
||||
assert len({id(db) for db in results}) == 1
|
||||
for db in results:
|
||||
assert registry.release(db) is True
|
||||
|
||||
def test_waiter_retries_after_cold_open_failure(self, tmp_path, monkeypatch):
|
||||
"""A failed elected opener must wake a peer to retry the path."""
|
||||
db_path = tmp_path / "state.db"
|
||||
first_entered = threading.Event()
|
||||
release_failure = threading.Event()
|
||||
open_calls = 0
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, path):
|
||||
self.db_path = path
|
||||
self._shared_registry_owned = False
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def _fail_then_open(path):
|
||||
nonlocal open_calls
|
||||
open_calls += 1
|
||||
if open_calls == 1:
|
||||
first_entered.set()
|
||||
assert release_failure.wait(5.0)
|
||||
raise OSError("transient open failure")
|
||||
return _FakeDB(path)
|
||||
|
||||
monkeypatch.setattr(registry, "_open_session_db", _fail_then_open)
|
||||
|
||||
def _acquire():
|
||||
try:
|
||||
results.append(registry.acquire(db_path))
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
first = threading.Thread(target=_acquire)
|
||||
second = threading.Thread(target=_acquire)
|
||||
first.start()
|
||||
assert first_entered.wait(5.0)
|
||||
second.start()
|
||||
time.sleep(0.1)
|
||||
release_failure.set()
|
||||
first.join(10.0)
|
||||
second.join(10.0)
|
||||
|
||||
assert not first.is_alive()
|
||||
assert not second.is_alive()
|
||||
assert open_calls == 2
|
||||
assert len(errors) == 1
|
||||
assert isinstance(errors[0], OSError)
|
||||
assert len(results) == 1
|
||||
assert registry.release(results[0]) is True
|
||||
|
||||
def test_equivalent_path_spellings_share_generation(self, tmp_path):
|
||||
"""Registry identity is the resolved file, not caller spelling."""
|
||||
db_path = tmp_path / "nested" / "state.db"
|
||||
equivalent = tmp_path / "nested" / ".." / "nested" / "state.db"
|
||||
|
||||
first = registry.acquire(db_path)
|
||||
second = registry.acquire(equivalent)
|
||||
assert first is second
|
||||
assert registry.release(first) is True
|
||||
assert registry.release(second) is True
|
||||
|
||||
def test_final_release_does_not_hold_registry_lock_during_close(self, tmp_path, monkeypatch):
|
||||
"""A final release's teardown (token-writer stop, WAL checkpoint,
|
||||
read-pool drain) must run OUTSIDE the registry lock — otherwise
|
||||
one state.db's close stalls acquisition for every other."""
|
||||
db_path = tmp_path / "state.db"
|
||||
db = registry.acquire(db_path)
|
||||
|
||||
teardown_entered = threading.Event()
|
||||
lock_released_during_teardown = threading.Event()
|
||||
|
||||
original_teardown = registry._teardown
|
||||
|
||||
def _slow_teardown(target):
|
||||
teardown_entered.set()
|
||||
# If teardown runs while the registry lock is held, this
|
||||
# acquire from another thread will deadlock or block until
|
||||
# teardown finishes. Give it a moment to observe.
|
||||
try:
|
||||
acquired = registry._lock.acquire(timeout=2.0)
|
||||
if acquired:
|
||||
lock_released_during_teardown.set()
|
||||
registry._lock.release()
|
||||
except Exception:
|
||||
pass
|
||||
original_teardown(target)
|
||||
|
||||
monkeypatch.setattr(registry, "_teardown", _slow_teardown)
|
||||
|
||||
result = threading.Event()
|
||||
|
||||
def _release():
|
||||
assert registry.release(db) is True
|
||||
result.set()
|
||||
|
||||
t = threading.Thread(target=_release)
|
||||
t.start()
|
||||
assert teardown_entered.wait(5.0), "teardown never ran"
|
||||
assert lock_released_during_teardown.wait(5.0), (
|
||||
"registry lock was HELD during teardown close — a slow WAL "
|
||||
"checkpoint here stalls every other state.db acquisition"
|
||||
)
|
||||
t.join(10.0)
|
||||
assert result.is_set()
|
||||
assert db._conn is None
|
||||
|
||||
def test_concurrent_acquire_and_release_no_deadlock(self, tmp_path):
|
||||
"""Hammer acquire/release from multiple threads — teardown
|
||||
contention must not deadlock or corrupt refcounts."""
|
||||
db_path = tmp_path / "state.db"
|
||||
errors = []
|
||||
|
||||
def _worker(n):
|
||||
try:
|
||||
for index in range(20):
|
||||
db = registry.acquire(db_path)
|
||||
try:
|
||||
db.create_session(
|
||||
session_id=f"worker-{n}-{index}",
|
||||
source="test",
|
||||
model="test-model",
|
||||
model_config={},
|
||||
system_prompt=None,
|
||||
)
|
||||
finally:
|
||||
registry.release(db)
|
||||
except Exception as exc: # pragma: no cover - failure path
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=_worker, args=(i,)) for i in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(30.0)
|
||||
assert not t.is_alive(), "worker deadlocked"
|
||||
|
||||
assert errors == []
|
||||
verifier = registry.acquire(db_path)
|
||||
try:
|
||||
with verifier._lock:
|
||||
assert verifier._conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
|
||||
finally:
|
||||
registry.release(verifier)
|
||||
stats = registry.stats()
|
||||
assert stats["live_generations"] == 0
|
||||
assert stats["retired_generations"] == 0
|
||||
|
||||
|
||||
class TestLegacyCloseSemantics:
|
||||
def test_close_on_shared_instance_releases_one_refcount(self, tmp_path):
|
||||
"""Legacy ``db.close()`` call sites must not leak refcounts: close()
|
||||
on a shared instance releases ONE reference — so the gateway's
|
||||
pre-registry close paths stay balanced — while never tearing down
|
||||
the connection other holders still use."""
|
||||
db_path = tmp_path / "state.db"
|
||||
a = registry.acquire(db_path)
|
||||
b = registry.acquire(db_path)
|
||||
assert a is b
|
||||
|
||||
# Legacy close: decrements, does not tear down (b still holds).
|
||||
a.close()
|
||||
assert b._conn is not None, "close() must not tear down a shared instance"
|
||||
|
||||
# The refcount is now 1 (b's); releasing b tears down.
|
||||
assert registry.release(b) is True
|
||||
assert b._conn is None
|
||||
stats = registry.stats()
|
||||
assert stats["live_generations"] == 0
|
||||
|
||||
def test_close_only_call_site_does_not_leak_refcount(self, tmp_path):
|
||||
"""A call site that acquires and only calls close() (the pre-#90837
|
||||
cleanup idiom) must return its reference — the exact leak class
|
||||
the 4-angle review flagged."""
|
||||
db_path = tmp_path / "state.db"
|
||||
for _ in range(5):
|
||||
db = registry.acquire(db_path)
|
||||
db.close()
|
||||
stats = registry.stats()
|
||||
assert stats["live_generations"] == 0, (
|
||||
f"acquire+close cycles leaked refcounts: {stats}"
|
||||
)
|
||||
assert stats["retired_generations"] == 0
|
||||
|
||||
|
||||
class TestAcquireSingleFlight:
|
||||
def test_concurrent_first_acquires_share_one_generation(self, tmp_path, monkeypatch):
|
||||
"""Two threads acquiring a cold path concurrently must end up
|
||||
sharing ONE generation, with the loser's instance torn down."""
|
||||
db_path = tmp_path / "state.db"
|
||||
real_open = registry._open_session_db
|
||||
gate = threading.Event()
|
||||
opened = []
|
||||
|
||||
def _gated_open(path):
|
||||
db = real_open(path)
|
||||
opened.append(db)
|
||||
# Hold the first open so a second thread can race in.
|
||||
if len(opened) == 1:
|
||||
gate.wait(5.0)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr(registry, "_open_session_db", _gated_open)
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def _acquire():
|
||||
try:
|
||||
results.append(registry.acquire(db_path))
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
t1 = threading.Thread(target=_acquire)
|
||||
t1.start()
|
||||
# Wait until the first open is in flight inside the lock window.
|
||||
deadline = time.monotonic() + 5.0
|
||||
while not opened and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
t2 = threading.Thread(target=_acquire)
|
||||
t2.start()
|
||||
gate.set()
|
||||
t1.join(10.0)
|
||||
t2.join(10.0)
|
||||
|
||||
assert errors == []
|
||||
assert len(results) == 2
|
||||
assert results[0] is results[1], "concurrent acquires must share one generation"
|
||||
assert len(opened) >= 1
|
||||
registry.release(results[0])
|
||||
registry.release(results[1])
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Quarantine of a live SessionDB handle after structural (non-FTS) corruption.
|
||||
|
||||
Field evidence (the #90837 lost/reordered-page-write class): a gateway kept
|
||||
retrying writes for ~50 minutes after ``gateway_routing`` reported
|
||||
``database disk image is malformed``; on SIGTERM the close-time
|
||||
``PRAGMA wal_checkpoint(PASSIVE)`` then wrote 15 pages to the wrong page
|
||||
numbers (page 1 received a ``messages_fts_trigram_data`` leaf) and the file
|
||||
stopped opening at all. Once structural corruption is observed on a handle
|
||||
the only safe policy is to stop touching the file.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB, StateDbCorruptError
|
||||
|
||||
|
||||
class _MalformedConn:
|
||||
"""Connection proxy whose every execute reports bare SQLITE_CORRUPT."""
|
||||
|
||||
def __init__(self, real_conn):
|
||||
self._real = real_conn
|
||||
|
||||
def execute(self, *args, **kwargs):
|
||||
raise sqlite3.DatabaseError("database disk image is malformed")
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._real, name)
|
||||
|
||||
|
||||
class TestQuarantineAfterStructuralCorruption:
|
||||
def test_structural_corruption_sets_sticky_flag_and_raises_typed(self, tmp_path):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
real_conn = db._conn
|
||||
try:
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
db._conn = _MalformedConn(real_conn)
|
||||
with pytest.raises(StateDbCorruptError, match="malformed") as excinfo:
|
||||
db.create_session(session_id="s2", source="cli", model="test")
|
||||
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
|
||||
assert db._db_corrupt is True
|
||||
# Structural damage must never be mistaken for FTS-scoped damage.
|
||||
assert db._fts_stale is False
|
||||
finally:
|
||||
db._conn = real_conn
|
||||
db.close()
|
||||
|
||||
|
||||
class _RecordingConn:
|
||||
"""Connection proxy that records every SQL text and delegates."""
|
||||
|
||||
def __init__(self, real_conn):
|
||||
self._real = real_conn
|
||||
self.recorded = []
|
||||
|
||||
def execute(self, sql, *args, **kwargs):
|
||||
self.recorded.append(str(sql))
|
||||
return self._real.execute(sql, *args, **kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._real, name)
|
||||
|
||||
|
||||
def _quarantined_db(tmp_path):
|
||||
"""A SessionDB whose first corrupt write already tripped the quarantine."""
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
real_conn = db._conn
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
db._conn = _MalformedConn(real_conn)
|
||||
with pytest.raises(StateDbCorruptError):
|
||||
db.create_session(session_id="s2", source="cli", model="test")
|
||||
db._conn = real_conn
|
||||
assert db._db_corrupt is True
|
||||
return db, real_conn
|
||||
|
||||
|
||||
class TestQuarantinedHandleStopsTouchingTheFile:
|
||||
def test_subsequent_writes_fail_fast_without_touching_connection(self, tmp_path):
|
||||
db, real_conn = _quarantined_db(tmp_path)
|
||||
recorder = _RecordingConn(real_conn)
|
||||
db._conn = recorder
|
||||
try:
|
||||
with pytest.raises(StateDbCorruptError):
|
||||
db.create_session(session_id="s3", source="cli", model="test")
|
||||
assert recorder.recorded == []
|
||||
finally:
|
||||
db._conn = real_conn
|
||||
db.close()
|
||||
|
||||
def test_close_skips_wal_checkpoint_when_quarantined(self, tmp_path, caplog):
|
||||
db, real_conn = _quarantined_db(tmp_path)
|
||||
recorder = _RecordingConn(real_conn)
|
||||
db._conn = recorder
|
||||
with caplog.at_level("WARNING", logger="hermes_state"):
|
||||
db.close()
|
||||
assert not any("wal_checkpoint" in sql for sql in recorder.recorded)
|
||||
assert db._conn is None
|
||||
assert any(
|
||||
"Skipping the close-time WAL checkpoint" in rec.getMessage()
|
||||
and "hermes sessions recover" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
)
|
||||
|
||||
def test_close_disables_sqlite_internal_checkpoint_on_py312(self, tmp_path):
|
||||
"""Quarantine must also stop SQLite's own last-connection checkpoint.
|
||||
|
||||
Skipping the explicit PRAGMA is not enough: sqlite3.Connection.close()
|
||||
runs an internal PASSIVE checkpoint and unlinks -wal/-shm unless
|
||||
SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE is set (Connection.setconfig,
|
||||
Python 3.12+). On 3.11 the switch is unavailable — skip there.
|
||||
"""
|
||||
flag = getattr(sqlite3, "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE", None)
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
if flag is None or not hasattr(db._conn, "setconfig"):
|
||||
db.close()
|
||||
pytest.skip("SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE needs Python 3.12+")
|
||||
real_conn = db._conn
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
assert real_conn.getconfig(flag) is False
|
||||
db._conn = _MalformedConn(real_conn)
|
||||
with pytest.raises(StateDbCorruptError):
|
||||
db.create_session(session_id="s2", source="cli", model="test")
|
||||
db._conn = real_conn
|
||||
# _halt_db_corrupt armed the no-checkpoint-on-close switch.
|
||||
assert real_conn.getconfig(flag) is True
|
||||
db.close()
|
||||
|
||||
def test_reopen_after_close_refused_when_quarantined(self, tmp_path, monkeypatch):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
db, real_conn = _quarantined_db(tmp_path)
|
||||
db.close()
|
||||
reopen = MagicMock()
|
||||
monkeypatch.setattr("hermes_state._connect_tracked_db", reopen)
|
||||
with pytest.raises(StateDbCorruptError, match="structural corruption"):
|
||||
db.create_session(session_id="s4", source="cli", model="test")
|
||||
reopen.assert_not_called()
|
||||
# The read fallback after close() goes through the same reopen path.
|
||||
with pytest.raises(StateDbCorruptError, match="refusing to reopen"):
|
||||
db.get_session("s1")
|
||||
reopen.assert_not_called()
|
||||
|
||||
|
||||
class TestQuarantineScope:
|
||||
def test_fts_scoped_corruption_does_not_trip_flag(self, tmp_path):
|
||||
"""Corrupt FTS shadow tables keep the existing fail-open detach path."""
|
||||
path = tmp_path / "state.db"
|
||||
db = SessionDB(db_path=path)
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
db.append_message("s1", role="user", content="hello world")
|
||||
raw = sqlite3.connect(str(path))
|
||||
raw.execute(
|
||||
"UPDATE messages_fts_data SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'"
|
||||
)
|
||||
raw.commit()
|
||||
raw.close()
|
||||
try:
|
||||
db.append_message("s1", role="user", content="healed append")
|
||||
assert db._db_corrupt is False
|
||||
assert db._fts_stale is True
|
||||
assert db._fts_enabled is False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_replaced_file_takes_precedence_over_corrupt(self, tmp_path):
|
||||
import os
|
||||
|
||||
from hermes_state import StateDbReplacedError
|
||||
|
||||
live = tmp_path / "state.db"
|
||||
other = tmp_path / "other.db"
|
||||
db = SessionDB(db_path=live)
|
||||
real_conn = db._conn
|
||||
try:
|
||||
db.create_session(session_id="s1", source="cli", model="test")
|
||||
if db._db_file_identity is None:
|
||||
pytest.skip("filesystem does not expose st_dev/st_ino")
|
||||
alt = SessionDB(db_path=other)
|
||||
alt.create_session("other", "cli")
|
||||
alt.close()
|
||||
os.replace(other, live)
|
||||
db._conn = _MalformedConn(real_conn)
|
||||
with pytest.raises(StateDbReplacedError):
|
||||
db.create_session(session_id="s2", source="cli", model="test")
|
||||
assert db._db_replaced is True
|
||||
assert db._db_corrupt is False
|
||||
finally:
|
||||
db._conn = real_conn
|
||||
db.close()
|
||||
|
||||
def test_classify_persistence_error_maps_quarantine_to_corrupt(self):
|
||||
from hermes_state import _STATE_DB_CORRUPT_MSG, classify_persistence_error
|
||||
|
||||
assert classify_persistence_error(StateDbCorruptError("x")) == "corrupt"
|
||||
# The stringified form (RPC boundaries) must classify the same way.
|
||||
assert classify_persistence_error(_STATE_DB_CORRUPT_MSG) == "corrupt"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _clean_registry():
|
||||
import hermes_state_registry as registry
|
||||
|
||||
registry.close_all()
|
||||
registry._generations.clear()
|
||||
registry._retired.clear()
|
||||
yield registry
|
||||
registry.close_all()
|
||||
registry._generations.clear()
|
||||
registry._retired.clear()
|
||||
|
||||
|
||||
class TestSharedRegistry:
|
||||
def test_holders_share_quarantine_and_close_all_skips_checkpoint(
|
||||
self, tmp_path, _clean_registry
|
||||
):
|
||||
registry = _clean_registry
|
||||
path = tmp_path / "state.db"
|
||||
holder_a = registry.acquire(path)
|
||||
holder_b = registry.acquire(path)
|
||||
assert holder_a is holder_b
|
||||
real_conn = holder_a._conn
|
||||
holder_a.create_session(session_id="s1", source="cli", model="test")
|
||||
|
||||
holder_a._conn = _MalformedConn(real_conn)
|
||||
with pytest.raises(StateDbCorruptError):
|
||||
holder_a.create_session(session_id="s2", source="cli", model="test")
|
||||
recorder = _RecordingConn(real_conn)
|
||||
holder_b._conn = recorder
|
||||
|
||||
with pytest.raises(StateDbCorruptError):
|
||||
holder_b.create_session(session_id="s3", source="cli", model="test")
|
||||
|
||||
registry.close_all()
|
||||
assert not any("wal_checkpoint" in sql for sql in recorder.recorded)
|
||||
assert holder_a._conn is None
|
||||
@@ -0,0 +1,299 @@
|
||||
"""File-identity guard on SessionDB writes (#89332).
|
||||
|
||||
When state.db is replaced out-of-band under a live handle, in-place FTS
|
||||
rebuild / fail-open cannot help: they operate on a generation mismatch.
|
||||
The store must fail loudly instead of limping.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import (
|
||||
SessionDB,
|
||||
StateDbReplacedError,
|
||||
classify_persistence_error,
|
||||
divert_session_transcript_jsonl,
|
||||
)
|
||||
|
||||
|
||||
def _make_db(path: Path, session_id: str, content: str) -> SessionDB:
|
||||
db = SessionDB(db_path=path)
|
||||
db.create_session(session_id, "cli")
|
||||
db.append_message(session_id, role="user", content=content)
|
||||
return db
|
||||
|
||||
|
||||
def _require_identity(db: SessionDB) -> None:
|
||||
if db._db_file_identity is None:
|
||||
pytest.skip("filesystem does not expose st_dev/st_ino for identity checks")
|
||||
|
||||
|
||||
def test_replace_with_new_inode_fails_loudly_without_fts_repair(tmp_path):
|
||||
live = tmp_path / "state.db"
|
||||
other = tmp_path / "other.db"
|
||||
db = _make_db(live, "live-sess", "original")
|
||||
_require_identity(db)
|
||||
alt = _make_db(other, "other-sess", "replacement")
|
||||
alt.close()
|
||||
|
||||
recorded = db._db_file_identity
|
||||
assert recorded is not None
|
||||
os.replace(other, live)
|
||||
assert _stat_changed(live, recorded)
|
||||
|
||||
with pytest.raises(StateDbReplacedError, match="replaced underneath"):
|
||||
db.append_message("live-sess", role="user", content="after-replace")
|
||||
|
||||
assert db._db_replaced is True
|
||||
# No FTS surgery ran: fail-open never detached the indexes.
|
||||
assert db._fts_enabled is True
|
||||
assert db._fts_stale is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_second_write_after_halt_does_not_attempt_repair(tmp_path):
|
||||
live = tmp_path / "state.db"
|
||||
other = tmp_path / "other.db"
|
||||
db = _make_db(live, "s", "a")
|
||||
_require_identity(db)
|
||||
alt = _make_db(other, "t", "b")
|
||||
alt.close()
|
||||
os.replace(other, live)
|
||||
with pytest.raises(StateDbReplacedError):
|
||||
db.append_message("s", role="user", content="first")
|
||||
with pytest.raises(StateDbReplacedError):
|
||||
db.append_message("s", role="user", content="second")
|
||||
assert db._fts_enabled is True
|
||||
assert db._fts_stale is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_same_file_fts_corruption_still_fails_open(tmp_path):
|
||||
"""Identity guard must not disable genuine in-file FTS recovery.
|
||||
|
||||
Since 18ac3c4fb6 the live write path never rebuilds FTS in place; the
|
||||
recovery contract is the fail-open detach (stale marker + triggers
|
||||
dropped) followed by a successful canonical retry.
|
||||
"""
|
||||
db = _make_db(tmp_path / "state.db", "s1", "hello world")
|
||||
_require_identity(db)
|
||||
identity = db._db_file_identity
|
||||
raw = sqlite3.connect(str(tmp_path / "state.db"))
|
||||
raw.execute(
|
||||
"UPDATE messages_fts_data SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'"
|
||||
)
|
||||
raw.commit()
|
||||
raw.close()
|
||||
db.append_message("s1", role="user", content="healed append")
|
||||
assert db._db_file_identity == identity
|
||||
assert db._db_replaced is False
|
||||
# Fail-open detach ran: canonical write landed, FTS marked stale.
|
||||
assert db._fts_stale is True
|
||||
assert db._fts_enabled is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_classify_replaced_is_not_disk_or_fts_repair():
|
||||
err = StateDbReplacedError(
|
||||
"FATAL: state.db was replaced underneath the gateway; refusing further writes"
|
||||
)
|
||||
assert classify_persistence_error(err) == "replaced"
|
||||
assert classify_persistence_error(str(err)) == "replaced"
|
||||
|
||||
|
||||
def test_new_sessiondb_on_replaced_path_records_new_identity(tmp_path):
|
||||
live = tmp_path / "state.db"
|
||||
other = tmp_path / "other.db"
|
||||
db = _make_db(live, "s", "a")
|
||||
old_id = db._db_file_identity
|
||||
_require_identity(db)
|
||||
db.close()
|
||||
alt = _make_db(other, "t", "b")
|
||||
alt.close()
|
||||
os.replace(other, live)
|
||||
reopened = SessionDB(db_path=live)
|
||||
try:
|
||||
assert reopened._db_file_identity != old_id
|
||||
reopened.append_message("t", role="user", content="adopted after reopen")
|
||||
assert reopened._db_replaced is False
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_fts_scoped_error_on_replaced_file_skips_fts_fail_open(tmp_path):
|
||||
"""Even FTS-provenance corruption must not authorize surgery on a
|
||||
replaced file. (A generic malformed error never reaches fail-open at
|
||||
all since the provenance classifier of #99652 rejects it earlier.)"""
|
||||
live = tmp_path / "state.db"
|
||||
other = tmp_path / "other.db"
|
||||
db = _make_db(live, "s", "a")
|
||||
_require_identity(db)
|
||||
alt = _make_db(other, "t", "b")
|
||||
alt.close()
|
||||
os.replace(other, live)
|
||||
|
||||
with pytest.raises(StateDbReplacedError):
|
||||
db._enter_fts_fail_open(
|
||||
sqlite3.DatabaseError(
|
||||
'fts5: corrupt structure record for table "messages_fts"'
|
||||
)
|
||||
)
|
||||
assert db._fts_enabled is True
|
||||
assert db._fts_stale is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_copyfile_same_inode_fails_loudly_without_fts_repair(tmp_path):
|
||||
"""``cp`` keeps st_ino; generation stamp must still halt (#89332)."""
|
||||
live = tmp_path / "state.db"
|
||||
other = tmp_path / "other.db"
|
||||
db = _make_db(live, "live-sess", "original")
|
||||
alt = _make_db(other, "other-sess", "replacement")
|
||||
live_app = db._db_file_application_id
|
||||
other_app = alt._db_file_application_id
|
||||
if not live_app or not other_app:
|
||||
alt.close()
|
||||
db.close()
|
||||
pytest.skip("generation stamp not recorded on this filesystem")
|
||||
assert live_app != other_app
|
||||
recorded = db._db_file_identity
|
||||
alt.close()
|
||||
shutil.copyfile(other, live)
|
||||
if recorded is not None:
|
||||
st = os.stat(live)
|
||||
assert (st.st_dev, st.st_ino) == recorded
|
||||
with pytest.raises(StateDbReplacedError, match="replaced underneath"):
|
||||
db.append_message("live-sess", role="user", content="after-cp")
|
||||
assert db._db_replaced is True
|
||||
assert db._fts_enabled is True
|
||||
assert db._fts_stale is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_divert_session_transcript_jsonl_appends(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
path = divert_session_transcript_jsonl(
|
||||
"sess-jsonl",
|
||||
[{"role": "user", "content": "hello-jsonl"}],
|
||||
)
|
||||
assert path == tmp_path / "sessions" / "sess-jsonl.jsonl"
|
||||
lines = path.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert json.loads(lines[-1])["content"] == "hello-jsonl"
|
||||
assert divert_session_transcript_jsonl("sess-jsonl", []) is None
|
||||
|
||||
|
||||
def _stat_changed(path: Path, recorded) -> bool:
|
||||
st = os.stat(path)
|
||||
return (st.st_dev, st.st_ino) != recorded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lock safety of the identity probe itself (#100368 / howtocorrupt §2.2).
|
||||
#
|
||||
# _read_sqlite_application_id runs on EVERY write against the LIVE state.db.
|
||||
# Before the _pread_db_header fix it did open("rb")/read/close, and that
|
||||
# close() cancelled every POSIX advisory lock this process held on the file
|
||||
# — including the WAL-mode DMS shared lock of the writer connection. These
|
||||
# tests measure the actual kernel lock table (/proc/locks), so they are
|
||||
# Linux-only; the hazard itself is POSIX-only.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _posix_locks_on(paths):
|
||||
"""Set of (inode, type, mode, start, end) locks held by this pid."""
|
||||
import sys as _sys
|
||||
if not _sys.platform.startswith("linux"):
|
||||
pytest.skip("lock-table probe requires /proc/locks (Linux)")
|
||||
inodes = {}
|
||||
for p in paths:
|
||||
try:
|
||||
inodes[os.stat(p).st_ino] = str(p)
|
||||
except OSError:
|
||||
continue
|
||||
pid = os.getpid()
|
||||
held = set()
|
||||
for line in Path("/proc/locks").read_text().splitlines():
|
||||
parts = line.split()
|
||||
try:
|
||||
lpid = int(parts[4])
|
||||
ino = int(parts[5].split(":")[2])
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
if lpid == pid and ino in inodes:
|
||||
held.add((ino, parts[1], parts[3], parts[6], parts[7]))
|
||||
return held
|
||||
|
||||
|
||||
def test_identity_probe_does_not_cancel_live_posix_locks(tmp_path):
|
||||
"""The on-write header probe must not drop the writer's DMS lock."""
|
||||
from hermes_state import _read_sqlite_application_id
|
||||
|
||||
live = tmp_path / "state.db"
|
||||
db = _make_db(live, "probe-sess", "seed")
|
||||
try:
|
||||
sidecars = [live, Path(str(live) + "-shm")]
|
||||
# Hold an open write transaction: that is when the connection holds
|
||||
# POSIX range locks on the main db file, and exactly the state a
|
||||
# concurrent _raise_if_db_replaced probe (another thread, same
|
||||
# process) can destroy.
|
||||
db._conn.execute("BEGIN IMMEDIATE")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET source = source WHERE id = 'probe-sess'"
|
||||
)
|
||||
before = _posix_locks_on(sidecars)
|
||||
assert before, "expected in-transaction WAL connection to hold POSIX locks"
|
||||
|
||||
for _ in range(3):
|
||||
_read_sqlite_application_id(live)
|
||||
|
||||
after = _posix_locks_on(sidecars)
|
||||
db._conn.rollback()
|
||||
lost = before - after
|
||||
assert not lost, (
|
||||
"identity probe cancelled POSIX locks held by the live "
|
||||
f"connection (howtocorrupt §2.2): {lost}"
|
||||
)
|
||||
# The decisive check: the WAL DMS shared lock on the MAIN db file
|
||||
# must survive. With the pre-fix open/read/close probe the close()
|
||||
# cancels it (it is already gone by the time the connection has run
|
||||
# its first identity check in __init__), leaving other processes
|
||||
# free to treat this writer as dead and rerun WAL-index recovery
|
||||
# underneath it.
|
||||
db_ino = os.stat(live).st_ino
|
||||
main_db_locks = {lk for lk in after if lk[0] == db_ino}
|
||||
assert main_db_locks, (
|
||||
"live writer connection holds no POSIX lock on state.db itself — "
|
||||
"the WAL DMS lock was cancelled by a raw open/close probe "
|
||||
"(howtocorrupt §2.2)"
|
||||
)
|
||||
# The connection must still be able to commit.
|
||||
db.append_message("probe-sess", role="user", content="post-probe")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_identity_probe_still_detects_replacement_after_fd_cache(tmp_path):
|
||||
"""The cached-fd probe rebinds when the path names a new inode."""
|
||||
from hermes_state import _read_sqlite_application_id
|
||||
|
||||
live = tmp_path / "state.db"
|
||||
other = tmp_path / "other.db"
|
||||
db = _make_db(live, "live-sess", "original")
|
||||
_require_identity(db)
|
||||
first = _read_sqlite_application_id(live) # populates the fd cache
|
||||
db.close()
|
||||
|
||||
alt = _make_db(other, "other-sess", "replacement")
|
||||
alt.close()
|
||||
os.replace(other, live)
|
||||
|
||||
second = _read_sqlite_application_id(live)
|
||||
assert second is not None
|
||||
assert second != first, (
|
||||
"probe kept reading the retired inode instead of rebinding to the "
|
||||
"replacement file"
|
||||
)
|
||||
@@ -0,0 +1,583 @@
|
||||
"""Tests for #65194: startup-time sweep of orphaned UI-stack sessions.
|
||||
|
||||
The TUI/desktop gateway reaps disconnected websocket sessions with an
|
||||
in-process ``threading.Timer`` grace timer. A gateway restart destroys the
|
||||
timer, so the session row stays ``ended_at IS NULL`` forever — nothing
|
||||
re-checks stale rows on the next boot. ``SessionDB.sweep_orphaned_sessions()``
|
||||
is the DB-level startup sweep that closes such rows with a distinct
|
||||
``end_reason='startup_orphan_reap'``.
|
||||
|
||||
Staleness requires BOTH ``started_at`` and the newest ``messages.timestamp``
|
||||
to be older than the cutoff:
|
||||
|
||||
* message-recency alone would sweep a freshly created compression/branch
|
||||
child that carries old *copied* message timestamps;
|
||||
* ``started_at`` alone would sweep a long-lived session that is still
|
||||
actively producing messages.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
IDLE_S = 6 * 3600 # mirror the TUI gateway's default session TTL
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _backdate_session(db: SessionDB, session_id: str, ts: float) -> None:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?", (ts, session_id)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def _set_message_timestamps(db: SessionDB, session_id: str, ts: float) -> None:
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp = ? WHERE session_id = ?", (ts, session_id)
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def _set_last_activity(db: SessionDB, session_id: str, ts: float) -> None:
|
||||
conn = db._conn
|
||||
assert conn is not None
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_activity_at = ? WHERE id = ?", (ts, session_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _make_session(
|
||||
db: SessionDB,
|
||||
session_id: str,
|
||||
*,
|
||||
source: str,
|
||||
started_at: float,
|
||||
message_at: float = None,
|
||||
) -> None:
|
||||
db.create_session(session_id, source=source)
|
||||
if message_at is not None:
|
||||
db.append_message(session_id, role="user", content="hello")
|
||||
_set_message_timestamps(db, session_id, message_at)
|
||||
_backdate_session(db, session_id, started_at)
|
||||
|
||||
|
||||
class TestSweepOrphanedSessions:
|
||||
def test_stale_tui_session_swept(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "stale-tui", source="tui", started_at=stale, message_at=stale)
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == ["stale-tui"]
|
||||
|
||||
row = db.get_session("stale-tui")
|
||||
assert row["ended_at"] is not None
|
||||
assert row["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_stale_desktop_session_swept(self, db):
|
||||
"""Desktop chat rows use the same gateway and the same Timer path."""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "stale-desktop", source="desktop", started_at=stale, message_at=stale
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == ["stale-desktop"]
|
||||
assert db.get_session("stale-desktop")["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_stale_subagent_session_swept(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "stale-sub", source="subagent", started_at=stale, message_at=stale
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == ["stale-sub"]
|
||||
assert db.get_session("stale-sub")["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_recent_message_spares_old_session(self, db):
|
||||
"""A long-lived session that is still talking is NOT an orphan."""
|
||||
stale = time.time() - 48 * 3600
|
||||
_make_session(
|
||||
db, "active", source="tui", started_at=stale, message_at=time.time()
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == []
|
||||
assert db.get_session("active")["ended_at"] is None
|
||||
|
||||
def test_recent_heartbeat_spares_old_session(self, db):
|
||||
"""A turn heartbeat is activity even before its next message lands."""
|
||||
stale = time.time() - 48 * 3600
|
||||
_make_session(
|
||||
db, "active-heartbeat", source="tui", started_at=stale, message_at=stale
|
||||
)
|
||||
_set_last_activity(db, "active-heartbeat", time.time())
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == []
|
||||
assert db.get_session("active-heartbeat")["ended_at"] is None
|
||||
|
||||
def test_fresh_session_with_old_copied_messages_spared(self, db):
|
||||
"""Compression/branch children copy history — old message timestamps
|
||||
on a just-created row must not get it swept."""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "fresh-child", source="tui", started_at=time.time(), message_at=stale
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == []
|
||||
assert db.get_session("fresh-child")["ended_at"] is None
|
||||
|
||||
def test_gateway_owned_source_not_swept(self, db):
|
||||
"""telegram/discord/... rows belong to the messaging gateway (#60609)."""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "tg-sess", source="telegram", started_at=stale, message_at=stale
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == []
|
||||
assert db.get_session("tg-sess")["ended_at"] is None
|
||||
|
||||
def test_already_ended_session_untouched(self, db):
|
||||
"""First end_reason wins — the sweep never rewrites history."""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "done", source="tui", started_at=stale, message_at=stale)
|
||||
db.end_session("done", "user_exit")
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == []
|
||||
assert db.get_session("done")["end_reason"] == "user_exit"
|
||||
|
||||
def test_stale_empty_session_swept_fresh_spared(self, db):
|
||||
"""Rows without messages fall back to started_at staleness."""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "stale-empty", source="tui", started_at=stale)
|
||||
_make_session(db, "fresh-empty", source="tui", started_at=time.time())
|
||||
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == ["stale-empty"]
|
||||
assert db.get_session("stale-empty")["end_reason"] == "startup_orphan_reap"
|
||||
assert db.get_session("fresh-empty")["ended_at"] is None
|
||||
|
||||
def test_exclude_ids_spares_live_row(self, db):
|
||||
"""A row this process still holds in memory must not be closed."""
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "live-tui", source="tui", started_at=stale, message_at=stale)
|
||||
_make_session(db, "dead-tui", source="tui", started_at=stale, message_at=stale)
|
||||
|
||||
swept = db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S, exclude_ids=("live-tui",)
|
||||
)
|
||||
assert swept == ["dead-tui"]
|
||||
assert db.get_session("live-tui")["ended_at"] is None
|
||||
assert db.get_session("dead-tui")["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_custom_sources_respected(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "stale-cli", source="cli", started_at=stale, message_at=stale)
|
||||
_make_session(db, "stale-tui", source="tui", started_at=stale, message_at=stale)
|
||||
|
||||
assert db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S, sources=("cli",)
|
||||
) == ["stale-cli"]
|
||||
assert db.get_session("stale-cli")["end_reason"] == "startup_orphan_reap"
|
||||
assert db.get_session("stale-tui")["ended_at"] is None
|
||||
|
||||
def test_explicit_source_scope_spares_gateway_sessions(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "stale-cron", source="cron", started_at=stale, message_at=stale
|
||||
)
|
||||
for sid, session_key in (
|
||||
("keyed-telegram", "telegram:chat:1"),
|
||||
("unkeyed-telegram", None),
|
||||
):
|
||||
db.create_session(sid, source="telegram", session_key=session_key)
|
||||
db.append_message(sid, role="user", content="hello")
|
||||
_set_message_timestamps(db, sid, stale)
|
||||
_backdate_session(db, sid, stale)
|
||||
|
||||
assert db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S, sources=("cron",)
|
||||
) == ["stale-cron"]
|
||||
assert db.get_session("stale-cron")["end_reason"] == "startup_orphan_reap"
|
||||
assert db.get_session("keyed-telegram")["ended_at"] is None
|
||||
assert db.get_session("unkeyed-telegram")["ended_at"] is None
|
||||
|
||||
def test_automatic_source_scope_spares_pinned_session(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "pinned", source="cli", started_at=stale, message_at=stale
|
||||
)
|
||||
db.set_session_pinned("pinned", True)
|
||||
|
||||
assert db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S,
|
||||
sources=("cli",),
|
||||
exclude_pinned=True,
|
||||
) == []
|
||||
assert db.get_session("pinned")["ended_at"] is None
|
||||
|
||||
def test_live_turn_lease_on_compression_lineage_spares_session(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "root", source="cli", started_at=stale, message_at=stale)
|
||||
db.end_session("root", "compression")
|
||||
db.create_session("tip", source="cli", parent_session_id="root")
|
||||
db.append_message("tip", role="user", content="continued")
|
||||
_set_message_timestamps(db, "tip", stale)
|
||||
_backdate_session(db, "tip", stale)
|
||||
assert db.try_acquire_session_turn_lease(
|
||||
"tip", "external-turn", ttl_seconds=300
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S, sources=("cli",)
|
||||
) == []
|
||||
assert db.get_session("tip")["ended_at"] is None
|
||||
|
||||
def test_active_compression_lock_spares_and_expiry_fences_owner(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "compressing", source="cli", started_at=stale, message_at=stale
|
||||
)
|
||||
assert db.try_acquire_compression_lock(
|
||||
"compressing", "compressor", ttl_seconds=300
|
||||
)
|
||||
|
||||
assert db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S, sources=("cli",)
|
||||
) == []
|
||||
|
||||
conn = db._conn
|
||||
assert conn is not None
|
||||
conn.execute(
|
||||
"UPDATE compression_locks SET expires_at = ? WHERE session_id = ?",
|
||||
(time.time() - 1, "compressing"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
assert db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S, sources=("cli",)
|
||||
) == ["compressing"]
|
||||
assert db.get_compression_lock_holder("compressing") is None
|
||||
assert db.refresh_compression_lock("compressing", "compressor") is False
|
||||
|
||||
def test_expired_turn_lease_does_not_block_sweep(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(
|
||||
db, "expired", source="cli", started_at=stale, message_at=stale
|
||||
)
|
||||
assert db.try_acquire_session_turn_lease(
|
||||
"expired", "expired-turn", ttl_seconds=300
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE session_turn_leases SET expires_at = ? WHERE conversation_id = ?",
|
||||
(time.time() - 1, "expired"),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
assert db.sweep_orphaned_sessions(
|
||||
max_idle_seconds=IDLE_S, sources=("cli",)
|
||||
) == ["expired"]
|
||||
assert db.get_session("expired")["end_reason"] == "startup_orphan_reap"
|
||||
assert db.refresh_session_turn_lease("expired", "expired-turn") is False
|
||||
|
||||
def test_auto_prune_closes_stale_state_owned_rows_but_spares_live_turns(self, db):
|
||||
stale = time.time() - 100 * 86400
|
||||
recent = time.time() - 86400
|
||||
for sid, source in (
|
||||
("orphan", "cli"),
|
||||
("live-turn", "cli"),
|
||||
("stale-cron", "cron"),
|
||||
("runtime-owned-ui", "tui"),
|
||||
):
|
||||
_make_session(db, sid, source=source, started_at=stale, message_at=stale)
|
||||
_set_last_activity(db, sid, stale)
|
||||
_make_session(
|
||||
db,
|
||||
"recent-orphan",
|
||||
source="cli",
|
||||
started_at=recent,
|
||||
message_at=recent,
|
||||
)
|
||||
_set_last_activity(db, "recent-orphan", recent)
|
||||
db.create_session(
|
||||
"keyed", source="telegram", session_key="telegram:chat:1"
|
||||
)
|
||||
_backdate_session(db, "keyed", stale)
|
||||
db.create_session("unkeyed-gateway", source="telegram")
|
||||
_backdate_session(db, "unkeyed-gateway", stale)
|
||||
_set_last_activity(db, "unkeyed-gateway", stale)
|
||||
assert db.try_acquire_session_turn_lease(
|
||||
"live-turn", "external-turn", ttl_seconds=300
|
||||
)
|
||||
db.register_backend_heartbeat(
|
||||
backend_id="unrelated-dashboard",
|
||||
pid=12345,
|
||||
started_at=time.time(),
|
||||
last_heartbeat=time.time(),
|
||||
)
|
||||
|
||||
first = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=0,
|
||||
vacuum=False,
|
||||
)
|
||||
|
||||
assert first["pruned"] == 0
|
||||
assert db.get_session("orphan")["end_reason"] == "startup_orphan_reap"
|
||||
assert db.get_session("stale-cron")["end_reason"] == "startup_orphan_reap"
|
||||
assert db.get_session("live-turn")["ended_at"] is None
|
||||
assert db.get_session("recent-orphan")["ended_at"] is None
|
||||
assert db.get_session("runtime-owned-ui")["ended_at"] is None
|
||||
assert db.get_session("keyed")["ended_at"] is None
|
||||
assert db.get_session("unkeyed-gateway")["ended_at"] is None
|
||||
|
||||
second = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=0,
|
||||
vacuum=False,
|
||||
)
|
||||
|
||||
assert second["pruned"] == 0
|
||||
assert db.get_session("orphan") is not None
|
||||
assert db.get_session("stale-cron") is not None
|
||||
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = ? WHERE id IN (?, ?)",
|
||||
(stale, "orphan", "stale-cron"),
|
||||
)
|
||||
db._conn.commit()
|
||||
third = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=0,
|
||||
vacuum=False,
|
||||
)
|
||||
|
||||
assert third["pruned"] == 2
|
||||
assert db.get_session("orphan") is None
|
||||
assert db.get_session("stale-cron") is None
|
||||
|
||||
def test_failed_maintenance_marker_keeps_newly_swept_row_recoverable(
|
||||
self, db, monkeypatch
|
||||
):
|
||||
stale = time.time() - 100 * 86400
|
||||
_make_session(
|
||||
db,
|
||||
"recoverable",
|
||||
source="cli",
|
||||
started_at=stale,
|
||||
message_at=stale,
|
||||
)
|
||||
_set_last_activity(db, "recoverable", stale)
|
||||
set_meta = db.set_meta
|
||||
fail_once = True
|
||||
|
||||
def flaky_set_meta(key, value):
|
||||
nonlocal fail_once
|
||||
if key == "last_auto_prune" and fail_once:
|
||||
fail_once = False
|
||||
raise RuntimeError("injected marker failure")
|
||||
return set_meta(key, value)
|
||||
|
||||
monkeypatch.setattr(db, "set_meta", flaky_set_meta)
|
||||
|
||||
first = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=0,
|
||||
vacuum=False,
|
||||
)
|
||||
retry = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=0,
|
||||
vacuum=False,
|
||||
)
|
||||
|
||||
assert first["error"] == "injected marker failure"
|
||||
assert retry["pruned"] == 0
|
||||
assert db.get_session("recoverable")["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_concurrent_auto_maintenance_preserves_the_recovery_window(
|
||||
self, db, monkeypatch
|
||||
):
|
||||
stale = time.time() - 100 * 86400
|
||||
_make_session(db, "concurrent", source="cli", started_at=stale, message_at=stale)
|
||||
_set_last_activity(db, "concurrent", stale)
|
||||
peer = SessionDB(db.db_path)
|
||||
read_barrier = threading.Barrier(2)
|
||||
second_done = threading.Event()
|
||||
release_first_prune = threading.Event()
|
||||
errors = []
|
||||
results = {}
|
||||
|
||||
for instance in (db, peer):
|
||||
get_meta = instance.get_meta
|
||||
|
||||
def synchronized_get_meta(key, *, _get_meta=get_meta):
|
||||
value = _get_meta(key)
|
||||
if key == "last_auto_prune":
|
||||
try:
|
||||
read_barrier.wait(timeout=1)
|
||||
except threading.BrokenBarrierError:
|
||||
pass
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(instance, "get_meta", synchronized_get_meta)
|
||||
|
||||
prune_sessions = db.prune_sessions
|
||||
|
||||
def delayed_prune(*args, **kwargs):
|
||||
assert release_first_prune.wait(timeout=5)
|
||||
return prune_sessions(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(db, "prune_sessions", delayed_prune)
|
||||
|
||||
def run(name, instance, *, done=None):
|
||||
try:
|
||||
results[name] = instance.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=24,
|
||||
vacuum=False,
|
||||
)
|
||||
except BaseException as exc: # pragma: no cover - asserted below
|
||||
errors.append(exc)
|
||||
finally:
|
||||
if done is not None:
|
||||
done.set()
|
||||
|
||||
first = threading.Thread(target=run, args=("first", db))
|
||||
second = threading.Thread(
|
||||
target=run, args=("second", peer), kwargs={"done": second_done}
|
||||
)
|
||||
try:
|
||||
first.start()
|
||||
second.start()
|
||||
assert second_done.wait(timeout=5)
|
||||
release_first_prune.set()
|
||||
finally:
|
||||
release_first_prune.set()
|
||||
first.join(timeout=5)
|
||||
second.join(timeout=5)
|
||||
peer.close()
|
||||
|
||||
assert not first.is_alive()
|
||||
assert not second.is_alive()
|
||||
assert errors == []
|
||||
assert sum(bool(result["skipped"]) for result in results.values()) == 1
|
||||
assert sum(int(result["pruned"]) for result in results.values()) == 0
|
||||
assert db.get_session("concurrent")["end_reason"] == "startup_orphan_reap"
|
||||
|
||||
def test_auto_prune_spares_compression_root_of_live_turn(self, db):
|
||||
stale = time.time() - 100 * 86400
|
||||
_make_session(db, "root", source="cli", started_at=stale, message_at=stale)
|
||||
db.end_session("root", "compression")
|
||||
db.create_session("tip", source="cli", parent_session_id="root")
|
||||
db.append_message("tip", role="user", content="continued")
|
||||
_set_message_timestamps(db, "tip", stale)
|
||||
_backdate_session(db, "tip", stale)
|
||||
_set_last_activity(db, "tip", stale)
|
||||
assert db.try_acquire_session_turn_lease(
|
||||
"tip", "external-turn", ttl_seconds=300
|
||||
)
|
||||
|
||||
result = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=0,
|
||||
vacuum=False,
|
||||
)
|
||||
|
||||
assert result["pruned"] == 0
|
||||
assert db.get_session("root") is not None
|
||||
assert db.get_session("tip")["ended_at"] is None
|
||||
|
||||
def test_auto_prune_spares_prior_sweep_row_with_new_turn_lease(self, db):
|
||||
stale = time.time() - 100 * 86400
|
||||
_make_session(db, "racy", source="cli", started_at=stale, message_at=stale)
|
||||
_set_last_activity(db, "racy", stale)
|
||||
db.end_session("racy", "startup_orphan_reap")
|
||||
assert db.try_acquire_session_turn_lease(
|
||||
"racy", "arriving-turn", ttl_seconds=300
|
||||
)
|
||||
|
||||
result = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=0,
|
||||
vacuum=False,
|
||||
)
|
||||
|
||||
assert result["pruned"] == 0
|
||||
assert db.get_session("racy") is not None
|
||||
|
||||
def test_auto_prune_spares_prior_sweep_row_with_new_compression_lock(self, db):
|
||||
stale = time.time() - 100 * 86400
|
||||
_make_session(
|
||||
db,
|
||||
"racy-compression",
|
||||
source="cli",
|
||||
started_at=stale,
|
||||
message_at=stale,
|
||||
)
|
||||
_set_last_activity(db, "racy-compression", stale)
|
||||
db.end_session("racy-compression", "startup_orphan_reap")
|
||||
assert db.try_acquire_compression_lock(
|
||||
"racy-compression", "arriving-compressor", ttl_seconds=300
|
||||
)
|
||||
|
||||
result = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90,
|
||||
min_interval_hours=0,
|
||||
vacuum=False,
|
||||
)
|
||||
|
||||
assert result["pruned"] == 0
|
||||
assert db.get_session("racy-compression") is not None
|
||||
|
||||
def test_returns_empty_on_empty_db(self, db):
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=IDLE_S) == []
|
||||
|
||||
def test_auto_prune_reports_closed_count_and_deletes_after_second_window(
|
||||
self, db
|
||||
):
|
||||
"""#54189 end-to-end: leaky producers (cron/kanban/subagent) never set
|
||||
``ended_at``; pass 1 closes them (reported via ``closed``), pass 2 —
|
||||
after a further retention window — deletes them, and a messaging row
|
||||
is never touched by either pass."""
|
||||
stale = time.time() - 200 * 86400
|
||||
for sid, source in (
|
||||
("cron-0", "cron"),
|
||||
("kanban-1", "kanban"),
|
||||
("subagent-2", "subagent"),
|
||||
("telegram-3", "telegram"),
|
||||
):
|
||||
_make_session(db, sid, source=source, started_at=stale, message_at=stale)
|
||||
_set_last_activity(db, sid, stale)
|
||||
|
||||
first = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90, min_interval_hours=0, vacuum=False
|
||||
)
|
||||
assert first["closed"] == 3
|
||||
assert first["pruned"] == 0
|
||||
for sid in ("cron-0", "kanban-1", "subagent-2"):
|
||||
assert db.get_session(sid)["end_reason"] == "startup_orphan_reap"
|
||||
assert db.get_session("telegram-3")["ended_at"] is None
|
||||
|
||||
# Simulate the next maintenance pass after another retention window.
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = ended_at - 91 * 86400 "
|
||||
"WHERE end_reason = 'startup_orphan_reap'"
|
||||
)
|
||||
db._conn.commit()
|
||||
second = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90, min_interval_hours=0, vacuum=False
|
||||
)
|
||||
assert second["closed"] == 0
|
||||
assert second["pruned"] == 3
|
||||
remaining = [r["id"] for r in db._conn.execute("SELECT id FROM sessions")]
|
||||
assert remaining == ["telegram-3"]
|
||||
|
||||
def test_zero_ttl_is_noop(self, db):
|
||||
stale = time.time() - 8 * 3600
|
||||
_make_session(db, "stale-tui", source="tui", started_at=stale, message_at=stale)
|
||||
assert db.sweep_orphaned_sessions(max_idle_seconds=0) == []
|
||||
assert db.get_session("stale-tui")["ended_at"] is None
|
||||
Reference in New Issue
Block a user