mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
480a1426b3
* fix(session): fail-closed history-commit handoff (#981) The deleted-workstream discovery is now a terminal, ws_id-keyed latch: keyed conversation commits refuse admission once the durable parent is gone (convergence finalizers and force-abandon are exempt), history handoff refuses to mint a proof token so /history fails closed with a 503 instead of silently wiping the pane, and the SSE stream carries a workstream_gone resync reason. Discarded commits leave a forensic log of commit keys and roles, never content. Conversation rows gain a commit_key (migration 071): keyed saves are idempotent under retry, validated against the full commit identity, and refused when they would cross a workstream deletion. The prune orphan category now requires a NULL alias plus a two-hour updated grace, with cutoffs computed at discovery time and carried into both dialects' rechecks. The mid-turn interjection queue is owner-partitioned with no per-site mode flags: pops take the acting principal's and unowned rows, other participants' rows are structurally retained, and enforcement lives at queue admission plus the shared before_spawn gates. The retraction ledger is bounded by open pop windows: pops open a window atomically with the queue delete, restores close their ids atomically with the ledger consume, every other exit closes through one helper, and misses for unheld ids record nothing. The workstream-gone latch refuses unattended wakes at all three gates (watcher spawn, claim, delivery pre-pop), and the retry dispatcher regained its pre-envelope cancel/error convergence net. Persistence-state reporting derives through the session bound to each UI instead of a registry lookup by id that failed open to healthy during tombstone retention. The dashboard roster no longer re-inserts ghost entries from trailing activity events, the history tool-outcome scan tolerates interleaved non-turn rows, and the shared handoff-deadline handle owns its own retirement. Single-sourced across call sites: keyed-commit row values, attachment save wrappers, tail-truncation and conflict-resolution bodies for both storage dialects; worker-slot lifecycle field sets; the direct-commit admission frame; queued-row layout accessors; the string-aware comment stripper shared by every JS harness suite. Refs #981 #964 * fix(session): sweep handoff fixes to their sibling surfaces The interactive replay loop treated a system row as a tool-batch boundary, so every tool result after an interleaved row vanished from that pane while the coordinator rendered the same history correctly. Only a conversational turn ends the batch window now, matching the shared outcome index. Accepted user turns clear the composer's attachment chips on the same viewer policy that settles optimistic bubbles rather than on having matched a local bubble, so a workstream created with an upload no longer keeps a chip for an attachment the create dispatch already consumed. The coordinator's raced-Stop arm emits the stream-end hook it inherits alongside the idle state, leaving no unfinalized bubble or unflushed tool output. Ending a session surfaces a failure toast when the request never lands or answers with a non-JSON body. The per-second persistence reconcile now probes each session without blocking: a workstream whose generation and handoff locks are held is skipped until the next pass instead of contending the locks every commit needs. The one-shot repair that gates workstream creation at capacity keeps a definite probe — it has no next pass, and the sessions likeliest to be contended are the ones whose unresolved journals emptied its candidate list. Single-sourced: the attachment lane builds its conversation row through the shared commit-identity builder; the ordinary worker exit releases its slot through the lifecycle owner; both operator surfaces snapshot their counters through one non-consuming helper; the replay preamble loses its per-kind wrappers and its config hook; the browser harness suites share one brace walker; and each in-flight history attempt is one record carrying both its abort controller and its deadline. Refs #981 #964
699 lines
29 KiB
Python
699 lines
29 KiB
Python
"""Tests for persisted compaction checkpoints (rehydration-deadlock fix).
|
|
|
|
Compaction swaps a session's in-memory history for a summary but leaves the full
|
|
transcript in storage. Without a durable marker, ``resume()`` reloaded the full
|
|
pre-compaction history, which on a long session — or one switched to a smaller-
|
|
context model — exceeds the window and deadlocks the first send.
|
|
|
|
The fix persists one ``_source="compaction"`` marker (summary + watermark) so
|
|
resume rehydrates ``[summary] + [rows after the watermark]`` while the full
|
|
history stays in storage for ``/history``/export. Covered here:
|
|
|
|
- ``get_compaction_watermark`` — the boundary id (max-summarized), with and
|
|
without a preserved tail, and on an empty workstream.
|
|
- ``load_message_turns`` (resume) — checkpoint-aware slice, latest-marker-wins,
|
|
preserved-tail handling, and the full-history fallbacks (no marker, malformed
|
|
marker) that keep every pre-checkpoint session loading exactly as before.
|
|
- ``load_messages`` (display) — markers stay invisible to ``/history``.
|
|
- End-to-end: ``_compact_messages`` writes the marker and a fresh ``resume()``
|
|
rehydrates the bounded view, not the full transcript.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from tests._session_helpers import make_session
|
|
from turnstone.core.session import _SummaryResult
|
|
from turnstone.core.storage._utils import _fork_turn_insert_row
|
|
from turnstone.core.trajectory import PROVENANCE_META_KEY, TurnProvenance, turns_from_dicts
|
|
|
|
|
|
def _marker_meta(watermark: int | None) -> str | None:
|
|
"""The marker's stored ``meta`` JSON (``None`` simulates a legacy/malformed marker)."""
|
|
return json.dumps({"watermark": watermark}) if watermark is not None else None
|
|
|
|
|
|
def _register(st, ws: str = "ws1") -> str:
|
|
st.register_workstream(ws, user_id="u1", title="t", kind="interactive")
|
|
return ws
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_compaction_watermark
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWatermark:
|
|
def test_preserve_tail_zero_is_max_id(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
ids = [st.save_message(ws, "user", f"m{i}") for i in range(5)]
|
|
assert st.get_compaction_watermark(ws, 0) == max(ids)
|
|
|
|
def test_preserve_tail_n_is_nth_newest(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
ids = sorted(st.save_message(ws, "user", f"m{i}") for i in range(5))
|
|
# Keep the newest 2 verbatim → boundary is the 3rd-newest id.
|
|
assert st.get_compaction_watermark(ws, 2) == ids[-3]
|
|
|
|
def test_preserve_tail_ignores_existing_markers(self, storage_backend):
|
|
# A compaction marker is saved as a NEW row but is not part of the
|
|
# preserved in-memory tail, so it must not shift the (preserve_tail+1)
|
|
# boundary — without the exclusion, this returns ids[-1] (the marker
|
|
# consumes an offset slot) and resume would drop a real tail row.
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
ids = [st.save_message(ws, "user", f"m{i}") for i in range(5)]
|
|
st.save_message(ws, "assistant", "SUM", source="compaction", meta=_marker_meta(max(ids)))
|
|
st.save_message(ws, "user", "m5")
|
|
# Real rows newest-first: m5, m4, m3, ... → 3rd-newest real row is m3.
|
|
assert st.get_compaction_watermark(ws, 2) == ids[-2]
|
|
|
|
def test_empty_workstream_is_none(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
assert st.get_compaction_watermark(ws, 0) is None
|
|
|
|
def test_preserve_tail_exceeding_row_count_is_none(self, storage_backend):
|
|
# Fewer rows than the preserved tail → no boundary, so compaction skips
|
|
# the marker rather than writing a watermark that points past the history.
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "only")
|
|
assert st.get_compaction_watermark(ws, 5) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# load_message_turns — checkpoint-aware resume
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckpointResume:
|
|
def test_loads_summary_plus_tail_not_full_history(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
for i in range(5):
|
|
st.save_message(ws, "user" if i % 2 == 0 else "assistant", f"old{i}")
|
|
watermark = st.get_compaction_watermark(ws, 0)
|
|
st.save_message(
|
|
ws, "assistant", "THE SUMMARY", source="compaction", meta=_marker_meta(watermark)
|
|
)
|
|
st.save_message(ws, "user", "new question")
|
|
st.save_message(ws, "assistant", "new answer")
|
|
|
|
texts = [t.text for t in st.load_message_turns(ws)]
|
|
assert texts == ["[Conversation summary]", "THE SUMMARY", "new question", "new answer"]
|
|
assert not any("old" in x for x in texts) # summarized prefix is gone
|
|
|
|
def test_preserved_tail_kept_after_summary(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
ids = sorted(st.save_message(ws, "user", f"m{i}") for i in range(4))
|
|
# Mid-turn compaction keeps the newest row (m3) verbatim.
|
|
watermark = st.get_compaction_watermark(ws, 1)
|
|
assert watermark == ids[-2]
|
|
st.save_message(ws, "assistant", "SUM", source="compaction", meta=_marker_meta(watermark))
|
|
|
|
texts = [t.text for t in st.load_message_turns(ws)]
|
|
assert texts == ["[Conversation summary]", "SUM", "m3"]
|
|
|
|
def test_latest_marker_wins(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "old")
|
|
st.save_message(
|
|
ws,
|
|
"assistant",
|
|
"SUMMARY 1",
|
|
source="compaction",
|
|
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
|
)
|
|
st.save_message(ws, "user", "mid")
|
|
st.save_message(
|
|
ws,
|
|
"assistant",
|
|
"SUMMARY 2",
|
|
source="compaction",
|
|
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
|
)
|
|
st.save_message(ws, "user", "after")
|
|
|
|
texts = [t.text for t in st.load_message_turns(ws)]
|
|
assert texts == ["[Conversation summary]", "SUMMARY 2", "after"]
|
|
assert "SUMMARY 1" not in texts and "old" not in texts and "mid" not in texts
|
|
|
|
def test_no_marker_loads_full_history(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
for i in range(3):
|
|
st.save_message(ws, "user", f"m{i}")
|
|
assert [t.text for t in st.load_message_turns(ws)] == ["m0", "m1", "m2"]
|
|
|
|
def test_malformed_marker_falls_back_to_full_history(self, storage_backend):
|
|
# A marker with no watermark (legacy/corrupt) must NOT slice — losing
|
|
# real messages is worse than reloading more than necessary.
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "a")
|
|
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=None)
|
|
st.save_message(ws, "user", "b")
|
|
texts = [t.text for t in st.load_message_turns(ws)]
|
|
assert "a" in texts and "b" in texts # no real message dropped
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# load_messages — display path keeps markers invisible
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDisplayPath:
|
|
def test_history_excludes_marker(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "q")
|
|
st.save_message(ws, "assistant", "a")
|
|
st.save_message(
|
|
ws,
|
|
"assistant",
|
|
"SUMMARY",
|
|
source="compaction",
|
|
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
|
)
|
|
|
|
contents = [m.get("content") for m in st.load_messages(ws)]
|
|
assert "SUMMARY" not in contents
|
|
assert contents == ["q", "a"] # true transcript, no injected summary
|
|
|
|
def test_include_compaction_projects_marker_as_system_row(self, storage_backend):
|
|
"""The /history display path (include_compaction=True) surfaces the
|
|
marker IN PLACE as a first-class system row — source="compaction",
|
|
meta = the marker's stored fields — so the UI re-renders its
|
|
compaction card after a reload. Export/search (default False)
|
|
stay on the drop path pinned above."""
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "q")
|
|
st.save_message(ws, "assistant", "a")
|
|
wm = st.get_compaction_watermark(ws, 0)
|
|
st.save_message(
|
|
ws,
|
|
"assistant",
|
|
"SUMMARY",
|
|
source="compaction",
|
|
meta=json.dumps(
|
|
{"watermark": wm, "before_tokens": 900, "after_tokens": 80, "trigger": "manual"}
|
|
),
|
|
)
|
|
st.save_message(ws, "user", "later question")
|
|
|
|
msgs = st.load_messages(ws, include_compaction=True)
|
|
assert [m.get("content") for m in msgs] == ["q", "a", "SUMMARY", "later question"]
|
|
marker = msgs[2]
|
|
assert marker["role"] == "system" # display row, not a fake assistant turn
|
|
assert marker.get("_source") == "compaction"
|
|
meta = marker.get("_source_meta")
|
|
assert meta == {
|
|
"watermark": wm,
|
|
"before_tokens": 900,
|
|
"after_tokens": 80,
|
|
"trigger": "manual",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: compaction writes the marker, resume is bounded
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_compaction_persists_checkpoint_and_resume_is_bounded(tmp_db, mock_openai_client):
|
|
"""The deadlock-fix proof: a session compacts, a fresh session reopens it,
|
|
and resume rehydrates [summary]+[tail] — never the full pre-compaction
|
|
transcript that would overflow the window on reopen."""
|
|
from unittest.mock import patch
|
|
|
|
from turnstone.core.memory import register_workstream, save_message
|
|
|
|
ws = "wsE2E"
|
|
register_workstream(ws, user_id="u1", name="t")
|
|
history = [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"turn {i}"} for i in range(6)
|
|
]
|
|
for h in history:
|
|
save_message(ws, h["role"], h["content"])
|
|
|
|
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
|
sess._ws_id = ws
|
|
sess.messages = turns_from_dicts(history)
|
|
sess._msg_tokens = [1] * len(history)
|
|
with patch.object(
|
|
sess,
|
|
"_summarize_blocks",
|
|
return_value=_SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
|
|
):
|
|
assert sess._compact_messages(auto=False) is True
|
|
|
|
# Conversation continues after the compaction.
|
|
save_message(ws, "user", "after compaction")
|
|
|
|
# A fresh session reopens the workstream.
|
|
sess2 = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
|
assert sess2.resume(ws) is True
|
|
texts = [t.text for t in sess2.messages]
|
|
|
|
assert texts[:2] == ["[Conversation summary]", "DENSE SUMMARY"]
|
|
assert "after compaction" in texts
|
|
assert not any(t.startswith("turn ") for t in texts) # full history NOT reloaded
|
|
|
|
|
|
def test_marker_watermark_read_blip_recovers_on_retry(tmp_db, mock_openai_client):
|
|
"""Round-3 review pin: a transient watermark-read failure aborts that
|
|
persist attempt — the journal classifies the marker row retrying with NO
|
|
meta bytes memoized — so the healthy retry re-reads and commits WITH a
|
|
watermark. Memoizing the failed read as "absent" would durably commit a
|
|
permanently checkpoint-less marker: full-history rehydration and an
|
|
immediate re-compaction on every reopen."""
|
|
from unittest.mock import patch
|
|
|
|
from turnstone.core.memory import register_workstream, save_message
|
|
from turnstone.core.storage._registry import get_storage
|
|
|
|
ws = "wsBLIP"
|
|
register_workstream(ws, user_id="u1", name="t")
|
|
history = [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"turn {i}"} for i in range(6)
|
|
]
|
|
for h in history:
|
|
save_message(ws, h["role"], h["content"])
|
|
|
|
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
|
sess._ws_id = ws
|
|
sess.messages = turns_from_dicts(history)
|
|
sess._msg_tokens = [1] * len(history)
|
|
|
|
st = get_storage()
|
|
real_watermark = st.get_compaction_watermark
|
|
reads: list[int] = []
|
|
|
|
def _flaky_watermark(ws_id: str, preserve_tail: int = 0) -> int | None:
|
|
reads.append(preserve_tail)
|
|
if len(reads) == 1:
|
|
raise RuntimeError("transient watermark blip")
|
|
return real_watermark(ws_id, preserve_tail)
|
|
|
|
from turnstone.core.session import ConversationPersistenceError
|
|
|
|
with (
|
|
patch.object(
|
|
sess,
|
|
"_summarize_blocks",
|
|
return_value=_SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
|
|
),
|
|
patch.object(st, "get_compaction_watermark", side_effect=_flaky_watermark),
|
|
):
|
|
# The first persist attempt dies at the read and surfaces like any
|
|
# initial durability failure; the in-memory compaction stays applied
|
|
# and the marker row is retained in the journal, nothing durable yet.
|
|
with pytest.raises(ConversationPersistenceError):
|
|
sess._compact_messages(auto=False)
|
|
assert sess.conversation_persistence_status()["state"] == "retrying"
|
|
assert st.get_compaction_checkpoint(ws) is None
|
|
sess._reconcile_pending_conversation_commits(_force_retry=True)
|
|
|
|
assert sess.conversation_persistence_status()["state"] == "healthy"
|
|
checkpoint = st.get_compaction_checkpoint(ws)
|
|
assert checkpoint is not None
|
|
assert checkpoint == real_watermark(ws, 0)
|
|
|
|
|
|
def test_compaction_summary_producer_survives_storage_round_trip(
|
|
storage_backend, mock_openai_client
|
|
):
|
|
"""Final summary producer and model provenance are durable metadata.
|
|
|
|
A compaction marker has no provider-native payload, so its producer belongs
|
|
in the marker's ``summary_producer`` meta field. Checkpoint reconstruction
|
|
maps that object to the summary Turn's ``meta.extra["source_meta"]`` while
|
|
retaining the accepted model alias/backend/generation/principal tuple in
|
|
the well-known provenance envelope.
|
|
"""
|
|
st = storage_backend
|
|
ws = _register(st, "ws-summary-producer")
|
|
history = [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"turn {i}"} for i in range(6)
|
|
]
|
|
for message in history:
|
|
st.save_message(ws, message["role"], message["content"])
|
|
|
|
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
|
sess._ws_id = ws
|
|
sess.messages = turns_from_dicts(history)
|
|
sess._msg_tokens = [1] * len(history)
|
|
provenance = TurnProvenance(
|
|
model_alias="summary-alias",
|
|
backend_model_id="summary-kernel",
|
|
registry_generation=12,
|
|
acting_principal_id="user-alice",
|
|
)
|
|
with pytest.MonkeyPatch.context() as monkeypatch:
|
|
monkeypatch.setattr(
|
|
sess,
|
|
"_summarize_blocks",
|
|
lambda *_args, **_kwargs: _SummaryResult(
|
|
text="DENSE SUMMARY",
|
|
producer="final-summary-producer",
|
|
provenance=provenance,
|
|
),
|
|
)
|
|
assert sess._compact_messages(auto=False) is True
|
|
|
|
marker = next(
|
|
message
|
|
for message in st.load_messages(ws, include_compaction=True)
|
|
if message.get("_source") == "compaction"
|
|
)
|
|
assert marker["_source_meta"]["summary_producer"] == "final-summary-producer"
|
|
assert "_provenance" not in marker
|
|
assert "user-alice" not in json.dumps(marker)
|
|
|
|
loaded = st.load_message_turns(ws)
|
|
assert [turn.text for turn in loaded[:2]] == ["[Conversation summary]", "DENSE SUMMARY"]
|
|
assert "source_meta" not in loaded[0].meta.extra
|
|
assert loaded[1].meta.extra["source_meta"]["summary_producer"] == "final-summary-producer"
|
|
assert loaded[1].meta.extra[PROVENANCE_META_KEY] == provenance.to_meta()
|
|
fork_row, _attachment_ids = _fork_turn_insert_row(
|
|
loaded[1],
|
|
"compaction-provenance-fork",
|
|
"2026-08-09T00:00:00",
|
|
)
|
|
fork_meta = json.loads(fork_row["meta"])
|
|
assert fork_meta["summary_producer"] == "final-summary-producer"
|
|
assert fork_meta[PROVENANCE_META_KEY] == provenance.to_meta()
|
|
|
|
reopened = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
|
assert reopened.resume(ws) is True
|
|
assert reopened.messages[1].text == "DENSE SUMMARY"
|
|
assert (
|
|
reopened.messages[1].meta.extra["source_meta"]["summary_producer"]
|
|
== "final-summary-producer"
|
|
)
|
|
assert reopened.messages[1].meta.extra[PROVENANCE_META_KEY] == provenance.to_meta()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Malformed / edge-case markers — the watermark guards and the empty tail
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMarkerEdges:
|
|
@pytest.mark.parametrize(
|
|
"meta",
|
|
[
|
|
json.dumps({"watermark": "5"}), # non-int (string)
|
|
json.dumps({"watermark": True}), # bool — True is an int subclass
|
|
json.dumps({}), # key absent
|
|
json.dumps({"watermark": None}), # null
|
|
],
|
|
)
|
|
def test_non_int_watermark_falls_back_to_full_history(self, storage_backend, meta):
|
|
# A watermark that isn't a real int must NOT slice (a True watermark
|
|
# would otherwise cut at id 1 and drop real history).
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "a")
|
|
st.save_message(ws, "assistant", "b")
|
|
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=meta)
|
|
st.save_message(ws, "user", "c")
|
|
texts = [t.text for t in st.load_message_turns(ws)]
|
|
assert "a" in texts and "b" in texts and "c" in texts # nothing sliced away
|
|
# ...and the malformed marker is DROPPED, not leaked as a stray summary turn.
|
|
assert "SUMMARY" not in texts
|
|
|
|
def test_marker_as_final_row_yields_empty_tail(self, storage_backend):
|
|
# watermark == max id, marker is the last row → resume is just the summary.
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
for i in range(3):
|
|
st.save_message(ws, "user", f"old{i}")
|
|
wm = st.get_compaction_watermark(ws, 0)
|
|
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=_marker_meta(wm))
|
|
assert [t.text for t in st.load_message_turns(ws)] == ["[Conversation summary]", "SUMMARY"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# checkpointed=False — export/audit gets the FULL transcript (markers dropped)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFullHistoryLoad:
|
|
def test_checkpointed_false_returns_full_history_without_marker(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
for i in range(4):
|
|
st.save_message(ws, "user" if i % 2 == 0 else "assistant", f"old{i}")
|
|
wm = st.get_compaction_watermark(ws, 0)
|
|
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=_marker_meta(wm))
|
|
st.save_message(ws, "user", "after")
|
|
|
|
# Resume (default) is bounded; export (checkpointed=False) is full + marker-free.
|
|
assert [t.text for t in st.load_message_turns(ws)] == [
|
|
"[Conversation summary]",
|
|
"SUMMARY",
|
|
"after",
|
|
]
|
|
full = [t.text for t in st.load_message_turns(ws, checkpointed=False)]
|
|
assert full == ["old0", "old1", "old2", "old3", "after"]
|
|
assert "SUMMARY" not in full and "[Conversation summary]" not in full
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# search — compaction markers stay out of search results
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSearchExclusion:
|
|
def test_search_history_excludes_markers(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "findme apple")
|
|
st.save_message(
|
|
ws,
|
|
"assistant",
|
|
"findme SUMMARY banana",
|
|
source="compaction",
|
|
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
|
)
|
|
contents = [r[3] for r in st.search_history("findme")]
|
|
assert any("apple" in (c or "") for c in contents) # real row matched
|
|
assert not any("SUMMARY" in (c or "") for c in contents) # marker excluded
|
|
# ...and normal rows (whose _source is NULL) are NOT dropped by the filter.
|
|
assert contents
|
|
|
|
def test_search_history_recent_excludes_markers(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "real")
|
|
st.save_message(
|
|
ws,
|
|
"assistant",
|
|
"SUMMARY",
|
|
source="compaction",
|
|
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
|
)
|
|
recent = [r[3] for r in st.search_history_recent(10)]
|
|
assert "real" in recent and "SUMMARY" not in recent
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# rewind / retry — compaction-safe truncation (never delete the summary backing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCompactionFloor:
|
|
def test_floor_and_count(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
for i in range(3):
|
|
st.save_message(ws, "user", f"old{i}") # summarized prefix
|
|
wm = st.get_compaction_watermark(ws, 0)
|
|
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=_marker_meta(wm))
|
|
st.save_message(ws, "user", "tail1")
|
|
st.save_message(ws, "assistant", "tail2")
|
|
assert st.get_compaction_floor(ws) == 4 # 3 prefix + 1 marker
|
|
assert st.count_messages(ws) == 6
|
|
|
|
def test_floor_zero_without_marker(self, storage_backend):
|
|
st = storage_backend
|
|
ws = _register(st)
|
|
st.save_message(ws, "user", "x")
|
|
assert st.get_compaction_floor(ws) == 0
|
|
|
|
|
|
def test_rewind_after_compaction_never_deletes_summary_backing(tmp_db, mock_openai_client):
|
|
"""The review's major rewind finding: after a compaction, a tail-trim must
|
|
delete from the storage TAIL and floor at the marker, not keep the oldest
|
|
summarized rows and drop the marker."""
|
|
from turnstone.core.memory import get_storage, register_workstream, save_message
|
|
|
|
ws = "wsRW"
|
|
register_workstream(ws, user_id="u1", name="t")
|
|
for i in range(3):
|
|
save_message(ws, "user", f"old{i}") # prefix
|
|
st = get_storage()
|
|
wm = st.get_compaction_watermark(ws, 0)
|
|
save_message(
|
|
ws, "assistant", "SUMMARY", source="compaction", meta=json.dumps({"watermark": wm})
|
|
)
|
|
save_message(ws, "user", "q1") # tail
|
|
save_message(ws, "assistant", "a1") # tail
|
|
assert st.get_compaction_floor(ws) == 4 and st.count_messages(ws) == 6
|
|
|
|
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
|
sess._ws_id = ws
|
|
|
|
# Trim one tail turn → keep = max(floor 4, total 6 - 1) = 5 → deletes only "a1".
|
|
sess._persist_truncation(1)
|
|
assert st.count_messages(ws) == 5
|
|
survived = [t.text for t in st.load_message_turns(ws)]
|
|
assert survived[:2] == ["[Conversation summary]", "SUMMARY"] # marker + prefix intact
|
|
assert "q1" in survived
|
|
|
|
# Over-deep trim → clamps at the floor; the marker + prefix still survive.
|
|
sess._persist_truncation(100)
|
|
assert st.count_messages(ws) == 4 # floored at prefix + marker
|
|
after = [t.text for t in st.load_message_turns(ws)]
|
|
assert after == ["[Conversation summary]", "SUMMARY"] # summary backing never deleted
|
|
|
|
|
|
def test_persist_truncation_uncompacted_matches_plain_tail_delete(tmp_db, mock_openai_client):
|
|
"""With no compaction (floor 0), the new path is identical to the old
|
|
keep=len(self.messages) tail delete."""
|
|
from turnstone.core.memory import get_storage, register_workstream, save_message
|
|
|
|
ws = "wsPlain"
|
|
register_workstream(ws, user_id="u1", name="t")
|
|
for i in range(5):
|
|
save_message(ws, "user", f"m{i}")
|
|
st = get_storage()
|
|
assert st.get_compaction_floor(ws) == 0
|
|
|
|
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
|
sess._ws_id = ws
|
|
sess._persist_truncation(2) # remove the last 2
|
|
assert st.count_messages(ws) == 3
|
|
|
|
|
|
def test_persist_truncation_propagates_atomic_storage_failure(tmp_db, mock_openai_client):
|
|
"""The strict backend error must reach the failure-atomic session caller."""
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.memory import get_storage, register_workstream, save_message
|
|
|
|
ws = "wsCnt"
|
|
register_workstream(ws, user_id="u1", name="t")
|
|
for i in range(4):
|
|
save_message(ws, "user", f"m{i}")
|
|
st = get_storage()
|
|
sess = make_session(client=mock_openai_client)
|
|
sess._ws_id = ws
|
|
with (
|
|
patch.object(
|
|
st,
|
|
"truncate_messages_tail",
|
|
side_effect=RuntimeError("injected atomic truncation failure"),
|
|
),
|
|
pytest.raises(RuntimeError, match="injected atomic truncation failure"),
|
|
):
|
|
sess._persist_truncation(2)
|
|
assert st.count_messages(ws) == 4 # nothing deleted
|
|
|
|
|
|
def test_persist_truncation_zero_is_a_storage_noop(tmp_db, mock_openai_client):
|
|
"""A no-op plan never opens the strict backend transaction."""
|
|
from unittest.mock import patch
|
|
|
|
from turnstone.core.memory import get_storage, register_workstream
|
|
|
|
ws = "wsFloor"
|
|
register_workstream(ws, user_id="u1", name="t")
|
|
st = get_storage()
|
|
sess = make_session(client=mock_openai_client)
|
|
sess._ws_id = ws
|
|
with patch.object(st, "truncate_messages_tail") as truncate:
|
|
assert sess._persist_truncation(0) == 0
|
|
truncate.assert_not_called()
|
|
|
|
|
|
def test_watermark_reads_inside_the_marker_persist_not_before_the_commit(
|
|
tmp_db, mock_openai_client
|
|
):
|
|
"""The boundary is cut in the ordered durable batch, at persist time.
|
|
|
|
A pre-commit snapshot can undercount the durable prefix whenever accepted
|
|
rows are still pending in the journal when compaction is admitted (they
|
|
land, FIFO, before the marker's persist executes). Reading inside the
|
|
persist closure names exactly the summarized prefix; memoization keeps a
|
|
keyed lost-ACK retry byte-identical.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from turnstone.core.memory import register_workstream, save_message
|
|
from turnstone.core.storage._registry import get_storage
|
|
|
|
ws = "wsWatermarkOrder"
|
|
register_workstream(ws, user_id="u1", name="t")
|
|
history = [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"turn {i}"} for i in range(6)
|
|
]
|
|
for h in history:
|
|
save_message(ws, h["role"], h["content"])
|
|
|
|
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
|
sess._ws_id = ws
|
|
sess.messages = turns_from_dicts(history)
|
|
sess._msg_tokens = [1] * len(history)
|
|
|
|
order: list[str] = []
|
|
real_journal = sess._journal_conversation_row_locked
|
|
# The persist closure reads the backend directly (bypassing the memory
|
|
# wrapper's error-to-None coercion), so the spy sits on the storage method.
|
|
st = get_storage()
|
|
real_watermark = st.get_compaction_watermark
|
|
|
|
def _journal_spy(**kwargs):
|
|
order.append("journal")
|
|
return real_journal(**kwargs)
|
|
|
|
def _watermark_spy(ws_id, preserve_tail=0):
|
|
order.append("watermark")
|
|
return real_watermark(ws_id, preserve_tail)
|
|
|
|
with (
|
|
patch.object(sess, "_journal_conversation_row_locked", side_effect=_journal_spy),
|
|
patch.object(st, "get_compaction_watermark", side_effect=_watermark_spy),
|
|
patch.object(
|
|
sess,
|
|
"_summarize_blocks",
|
|
return_value=_SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
|
|
),
|
|
):
|
|
assert sess._compact_messages(auto=False) is True
|
|
|
|
assert order == ["journal", "watermark"], order
|
|
|
|
# The durable marker's boundary covers every row persisted before it.
|
|
from turnstone.core.storage import get_storage
|
|
|
|
rows = get_storage().load_messages(ws, repair=False, include_compaction=True)
|
|
marker = next(r for r in rows if r.get("_source") == "compaction")
|
|
watermark = (marker.get("_source_meta") or {}).get("watermark")
|
|
plain_ids = [r["id"] for r in rows if r.get("_source") != "compaction" if "id" in r]
|
|
assert isinstance(watermark, int)
|
|
if plain_ids:
|
|
assert watermark >= max(plain_ids)
|