Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -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"]
+44
View File
@@ -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