Files
turnstone/tests/test_history_total_prefix.py
T
Patrick Buckley 480a1426b3 Fail-closed history-commit handoff (#1005)
* 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
2026-08-11 04:18:36 -07:00

865 lines
31 KiB
Python

"""Adversarial contract tests for a total live conversation-row prefix.
Each test freezes a different accepted-row boundary after the UI-visible
transition but before durable acknowledgement. The authoritative history
handoff must expose one ordered logical prefix at every cut: no later USER row
without its TOOL/SYSTEM predecessors, and no accepted cancellation/compaction
row that exists only on one side of the REST-to-SSE bootstrap.
The gates are event-driven; timeouts are diagnostics, not race scheduling.
"""
from __future__ import annotations
import base64
import contextlib
import copy
import hashlib
import json
import threading
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
import pytest
from tests._session_helpers import make_result, make_session
from tests.test_history_commit_handoff import _send_environment, _start_send
from tests.test_session_manager import _make_manager
from turnstone.core import session as session_module
from turnstone.core import session_worker
from turnstone.core.attachment_buffer import get_attachment_buffer
from turnstone.core.attachments import Attachment, resolve_staged_attachments
from turnstone.core.storage._registry import get_storage
from turnstone.core.trajectory import turns_from_dicts
if TYPE_CHECKING:
from collections.abc import Callable
_TOOL_CALL = {
"id": "call-total-prefix",
"type": "function",
"function": {"name": "prefix_probe", "arguments": "{}"},
}
class _PrefixStore:
"""Small keyed conversation store with a selectable pre-commit gate."""
def __init__(
self,
*,
block_when: Callable[[str, str | None, dict[str, Any]], bool] | None = None,
ambiguous_when: Callable[[str, str | None, dict[str, Any]], bool] | None = None,
) -> None:
self._lock = threading.Lock()
self._next_id = 1
self._rows: list[dict[str, Any]] = []
self._ids_by_key: dict[tuple[str, str], int] = {}
self._block_when = block_when or (lambda _role, _content, _kwargs: False)
self.ambiguous_when = ambiguous_when or (lambda _role, _content, _kwargs: False)
self.entered = threading.Event()
self.release = threading.Event()
def __call__(
self,
ws_id: str,
role: str,
content: str | None,
tool_name: str | None = None,
**kwargs: Any,
) -> int:
if self._block_when(role, content, kwargs):
self.entered.set()
assert self.release.wait(5), "test did not release conversation persistence"
row: dict[str, Any] = {"role": role, "content": content or ""}
if tool_name:
row["name"] = tool_name
tool_call_id = kwargs.get("tool_call_id")
if tool_call_id:
row["tool_call_id"] = tool_call_id
source = kwargs.get("source")
if source:
row["_source"] = source
event_id = kwargs.get("event_id")
if isinstance(event_id, int) and not isinstance(event_id, bool):
row["_event_id"] = event_id
if kwargs.get("is_error"):
row["is_error"] = True
raw_tool_calls = kwargs.get("tool_calls")
if raw_tool_calls:
row["tool_calls"] = json.loads(raw_tool_calls)
raw_provider_data = kwargs.get("provider_data")
if raw_provider_data:
row["_provider_content"] = json.loads(raw_provider_data)
producer = kwargs.get("producer")
if producer:
row["_producer"] = producer
raw_meta = kwargs.get("meta")
if raw_meta:
parsed_meta = json.loads(raw_meta)
if role == "user" and parsed_meta.get("sender"):
row["_sender"] = parsed_meta["sender"]
else:
row["_source_meta"] = parsed_meta
commit_key = kwargs.get("commit_key")
if isinstance(commit_key, str) and commit_key:
row["_commit_key"] = commit_key
with self._lock:
identity = (ws_id, commit_key) if isinstance(commit_key, str) and commit_key else None
existing = self._ids_by_key.get(identity) if identity is not None else None
if existing is not None:
row_id = existing
else:
row_id = self._next_id
self._next_id += 1
self._rows.append(row)
if identity is not None:
self._ids_by_key[identity] = row_id
if self.ambiguous_when(role, content, kwargs):
return 0
return row_id
def seed(self, *rows: dict[str, Any]) -> None:
with self._lock:
self._rows.extend(copy.deepcopy(rows))
self._next_id += len(rows)
def snapshot(self, overscan: int = 0) -> list[dict[str, Any]]:
# The store keeps the full prefix; the widened-window overscan the
# production loader applies to a tail-bounded read is a no-op here.
del overscan
with self._lock:
return copy.deepcopy(self._rows)
def _ready_session(**kwargs: Any) -> Any:
session = make_session(**kwargs)
session._title_generated = True
session._system_composed_with_context = True
return session
def _gate_pending_before_visibility(
session: Any,
*,
matches: Callable[[dict[str, Any]], bool],
entered: threading.Event,
release: threading.Event,
) -> Callable[[Any], int]:
"""Gate a journal implementation before it acquires the visibility lane.
The store carries the same gate as a compatibility fallback for the
currently unjournaled implementation. Once all row kinds use the generic
pending path, this wrapper is the deterministic cut and the store's wait is
already released by the time persistence reaches it.
"""
persist = session._persist_pending_conversation_commit
def _gated(entry: Any) -> int:
if matches(entry.message):
entered.set()
assert release.wait(5), "test did not release pending-row persistence"
return persist(entry)
return _gated
def _roles_and_content(rows: list[dict[str, Any]]) -> list[tuple[str, str]]:
return [(str(row.get("role") or ""), str(row.get("content") or "")) for row in rows]
def test_tool_system_user_fold_is_one_visible_causal_prefix(tmp_db: Any) -> None:
"""A trailing USER may not overtake its TOOL and SYSTEM predecessors."""
session = _ready_session()
store = _PrefixStore(block_when=lambda role, _content, _kwargs: role == "tool")
persist_release = store.release
def _execute_tools(*_args: Any, **_kwargs: Any) -> tuple[list[tuple[str, str]], str]:
return [(_TOOL_CALL["id"], "tool output")], "approval feedback"
gated_pending = _gate_pending_before_visibility(
session,
matches=lambda message: message.get("role") == "tool",
entered=store.entered,
release=persist_release,
)
with (
_send_environment(
session,
[
make_result("", tool_calls=[_TOOL_CALL]),
make_result("final answer"),
],
store,
_execute_tools,
),
patch.object(
session,
"_collect_advisories",
return_value=[("correction", "guarded operator context", {})],
),
patch.object(
session,
"_persist_pending_conversation_commit",
side_effect=gated_pending,
),
):
sender, send_errors = _start_send(session, "opening user")
assert store.entered.wait(5), "tool-row persistence never reached the frozen cut"
rows_during, _token = session.capture_history_handoff(store.snapshot)
persist_release.set()
sender.join(5)
assert not sender.is_alive()
assert send_errors == []
assert _roles_and_content(rows_during)[-3:] == [
("tool", "tool output"),
("system", "guarded operator context"),
("user", "approval feedback"),
]
assert all(row.get("_commit_key") for row in rows_during[-3:])
def test_initial_user_and_nudge_are_visible_in_append_order(tmp_db: Any) -> None:
"""The initialization batch cannot expose USER without its accepted nudge."""
session = _ready_session()
store = _PrefixStore()
persistence_entered = threading.Event()
persistence_release = threading.Event()
def _emit_init_nudge(*, deferred_persistence: list[Callable[[], None]] | None = None) -> None:
session._append_system_turn(
"start",
"initial metacognitive nudge",
deferred_persistence=deferred_persistence,
)
gated_pending = _gate_pending_before_visibility(
session,
matches=lambda message: (
message.get("role") == "user" and message.get("content") == "opening user"
),
entered=persistence_entered,
release=persistence_release,
)
with (
_send_environment(
session,
[make_result("answer")],
store,
MagicMock(return_value=([], None)),
),
patch.object(session, "_emit_pending_user_nudges", side_effect=_emit_init_nudge),
patch.object(
session,
"_persist_pending_conversation_commit",
side_effect=gated_pending,
),
):
sender, send_errors = _start_send(session, "opening user")
assert persistence_entered.wait(5), "opening USER did not reach the frozen cut"
rows_during, _token = session.capture_history_handoff(store.snapshot)
persistence_release.set()
sender.join(5)
assert not sender.is_alive()
assert send_errors == []
assert _roles_and_content(rows_during)[-2:] == [
("user", "opening user"),
("system", "initial metacognitive nudge"),
]
assert all(row.get("_commit_key") for row in rows_during[-2:])
def test_zero_token_cancelled_partial_crossing_forces_history_repair(tmp_db: Any) -> None:
"""A zero-token cancellation marker is accepted history, not an SSE ghost."""
marker = "[generation cancelled before completion]"
session = _ready_session()
store = _PrefixStore(
block_when=lambda role, content, _kwargs: role == "assistant" and content == marker
)
stream_entered = threading.Event()
release_cancel = threading.Event()
def _cancel_before_first_token(_generation: int) -> Any:
stream_entered.set()
assert release_cancel.wait(5), "test did not release zero-token cancellation"
session._cancelled_partial_msg = {"role": "assistant", "content": ""}
raise session_module.GenerationCancelled()
gated_pending = _gate_pending_before_visibility(
session,
matches=lambda message: (
message.get("role") == "assistant" and message.get("content") == marker
),
entered=store.entered,
release=store.release,
)
registration: Any = None
with (
_send_environment(
session,
[make_result("unused")],
store,
MagicMock(return_value=([], None)),
),
patch.object(session, "_stream_response", side_effect=_cancel_before_first_token),
patch.object(
session,
"_persist_pending_conversation_commit",
side_effect=gated_pending,
),
):
sender, send_errors = _start_send(session, "cancel immediately")
assert stream_entered.wait(5), "send never reached its zero-token stream"
_before_rows, token_before_marker = session.capture_history_handoff(store.snapshot)
release_cancel.set()
assert store.entered.wait(5), "cancel marker did not reach the frozen persistence cut"
rows_during, token_during = session.capture_history_handoff(store.snapshot)
registration = session.register_listener_for_history_handoff(token_before_marker)
store.release.set()
sender.join(5)
if registration is not None:
session.ui._unregister_listener(registration[0])
assert not sender.is_alive()
assert send_errors == []
assert token_during != token_before_marker
assert registration is None
assert _roles_and_content(rows_during)[-1] == ("assistant", marker)
assert rows_during[-1].get("_commit_key")
def test_compaction_end_crossing_is_visible_to_fresh_history_handoff(tmp_db: Any) -> None:
"""A listener born after compaction END must not miss its checkpoint card."""
summary = "bounded compacted summary"
session = _ready_session()
seed_rows = [
{"role": "user", "content": "old question"},
{"role": "assistant", "content": "old answer"},
]
session.messages = turns_from_dicts(seed_rows)
session._msg_tokens = [2, 2]
store = _PrefixStore(
block_when=lambda role, _content, kwargs: (
role == "assistant" and kwargs.get("source") == session_module.COMPACTION_SOURCE
)
)
store.seed(*seed_rows)
_rows_before, token_before = session.capture_history_handoff(store.snapshot)
gated_pending = _gate_pending_before_visibility(
session,
matches=lambda message: message.get("_source") == session_module.COMPACTION_SOURCE,
entered=store.entered,
release=store.release,
)
compacted: list[bool] = []
compact_errors: list[BaseException] = []
def _compact() -> None:
try:
compacted.append(session._compact_messages())
except BaseException as exc: # pragma: no cover - diagnostic capture
compact_errors.append(exc)
registration: Any = None
with (
patch.object(
session,
"_summarize_blocks",
return_value=session_module._SummaryResult(
text=summary,
producer="openai-compatible",
),
),
patch.object(get_storage(), "get_compaction_watermark", return_value=2),
patch("turnstone.core.session.save_message", side_effect=store),
patch.object(
session,
"_persist_pending_conversation_commit",
side_effect=gated_pending,
),
):
worker = threading.Thread(target=_compact, daemon=True)
worker.start()
reached_marker = store.entered.wait(5)
if not reached_marker:
store.release.set()
worker.join(5)
assert reached_marker, (
"compaction marker never reached the frozen cut; "
f"errors={compact_errors!r}, results={compacted!r}"
)
rows_during, token_during = session.capture_history_handoff(store.snapshot)
registration = session.register_listener_for_history_handoff(token_before)
store.release.set()
worker.join(5)
if registration is not None:
session.ui._unregister_listener(registration[0])
assert not worker.is_alive()
assert compact_errors == []
assert compacted == [True]
assert token_during != token_before
assert registration is None
markers = [row for row in rows_during if row.get("_source") == session_module.COMPACTION_SOURCE]
assert len(markers) == 1
assert markers[0]["content"] == summary
assert markers[0].get("_commit_key")
def test_attachment_ownership_transfers_at_journal_admission(tmp_db: Any) -> None:
"""Accepted journal bytes leave staging; a rejected pre-admission turn does not."""
buffer = get_attachment_buffer()
buffer.clear()
ws_id = "ws-total-prefix-attachments"
user_id = "attachment-owner"
session = _ready_session(ws_id=ws_id, user_id=user_id)
first = buffer.stage(
ws_id=ws_id,
user_id=user_id,
filename="owned.txt",
mime_type="text/plain",
kind="text",
content=b"journal owns these bytes",
)
attachment = Attachment(
attachment_id=first.attachment_id,
filename=first.filename,
mime_type=first.mime_type,
kind=first.kind,
content=first.content,
)
deferred: list[Callable[[], None]] = []
try:
session._append_user_turn(
"first accepted row",
(attachment,),
send_id="send-first",
deferred_persistence=deferred,
)
removed_at_admission = buffer.get(first.attachment_id, ws_id=ws_id, user_id=user_id) is None
second_resolution, _taken, _dropped = resolve_staged_attachments(
[first.attachment_id], ws_id, user_id
)
rejected_ws_id = "ws-total-prefix-rejected-attachment"
rejected = buffer.stage(
ws_id=rejected_ws_id,
user_id=user_id,
filename="retry.txt",
mime_type="text/plain",
kind="text",
content=b"must remain retryable",
)
rejected_session = _ready_session(ws_id=rejected_ws_id, user_id=user_id)
rejected_attachment = Attachment(
attachment_id=rejected.attachment_id,
filename=rejected.filename,
mime_type=rejected.mime_type,
kind=rejected.kind,
content=rejected.content,
)
rejected_session._publication_shutdown = True
accepted = rejected_session._commit_for_generation(
0,
lambda durable: rejected_session._append_user_turn(
"must not be admitted",
(rejected_attachment,),
send_id="send-rejected",
deferred_persistence=durable,
),
)
retained_before_admission = (
buffer.get(rejected.attachment_id, ws_id=rejected_ws_id, user_id=user_id) is not None
)
finally:
buffer.clear()
assert deferred, "journal admission must retain a retryable durable closure"
assert session.has_unresolved_conversation_persistence() is True
assert removed_at_admission is True
assert second_resolution == []
assert accepted is False
assert retained_before_admission is True
def test_system_lost_ack_reconciles_one_keyed_row(tmp_db: Any) -> None:
"""A committed SYSTEM row with a lost ACK is never duplicated or lost."""
session = _ready_session()
store = _PrefixStore(ambiguous_when=lambda role, _content, _kwargs: role == "system")
with (
patch("turnstone.core.session.save_message", side_effect=store),
pytest.raises(session_module.ConversationPersistenceError),
):
session._append_system_turn("correction", "accepted operator context")
assert session.has_unresolved_conversation_persistence() is True
assert len(store.snapshot()) == 1
rows, _token = session.capture_history_handoff(store.snapshot)
assert _roles_and_content(rows) == [("system", "accepted operator context")]
assert rows[0].get("_commit_key")
assert session.has_unresolved_conversation_persistence() is False
def test_mixed_batch_failure_stops_durable_suffix_but_keeps_visible_prefix(
tmp_db: Any,
) -> None:
"""A TOOL 0/0 stops SYSTEM/USER saves while all accepted rows stay visible."""
session = _ready_session()
store = _PrefixStore(ambiguous_when=lambda role, _content, _kwargs: role == "tool")
def _execute_tools(*_args: Any, **_kwargs: Any) -> tuple[list[tuple[str, str]], str]:
return [(_TOOL_CALL["id"], "tool output")], "approval feedback"
with (
_send_environment(
session,
[make_result("", tool_calls=[_TOOL_CALL])],
store,
_execute_tools,
),
patch.object(
session,
"_collect_advisories",
return_value=[("correction", "guarded operator context", {})],
),
pytest.raises(session_module.ConversationPersistenceError),
):
session.send("opening user")
durable = store.snapshot()
assert _roles_and_content(durable)[-1] == ("tool", "tool output")
assert ("system", "guarded operator context") not in _roles_and_content(durable)
assert ("user", "approval feedback") not in _roles_and_content(durable)
rows, _token = session.capture_history_handoff(store.snapshot)
assert _roles_and_content(rows)[-3:] == [
("tool", "tool output"),
("system", "guarded operator context"),
("user", "approval feedback"),
]
assert session.has_unresolved_conversation_persistence() is True
store.ambiguous_when = lambda _role, _content, _kwargs: False
with patch("turnstone.core.session.save_message", side_effect=store):
assert session.reconcile_unresolved_persistence_if_due(now=float("inf")) is True
assert session.has_unresolved_conversation_persistence() is False
assert _roles_and_content(store.snapshot())[-3:] == _roles_and_content(rows)[-3:]
def test_multi_tool_rows_keep_distinct_repair_event_ids(tmp_db: Any) -> None:
"""Each deferred TOOL closure captures its own admission event cursor."""
session = _ready_session()
store = _PrefixStore()
tool_calls = [
_TOOL_CALL,
{
"id": "call-total-prefix-2",
"type": "function",
"function": {"name": "prefix_probe", "arguments": "{}"},
},
]
def _execute_tools(*_args: Any, **_kwargs: Any) -> tuple[list[tuple[str, str]], str]:
return [(tool_calls[0]["id"], "first"), (tool_calls[1]["id"], "second")], ""
with _send_environment(
session,
[make_result("", tool_calls=tool_calls), make_result("done")],
store,
_execute_tools,
):
session.send("run both")
tool_rows = [row for row in store.snapshot() if row.get("role") == "tool"]
assert [row.get("tool_call_id") for row in tool_rows] == [
"call-total-prefix",
"call-total-prefix-2",
]
event_ids = [row.get("_event_id") for row in tool_rows]
assert all(isinstance(event_id, int) for event_id in event_ids)
assert event_ids[0] < event_ids[1]
def test_tool_attachment_lost_ack_retries_without_refcount_replay(tmp_db: Any) -> None:
"""Session-level TOOL retry keeps its row/blob/ref-list one atomic commit."""
from turnstone.core.memory import register_workstream
from turnstone.core.storage import get_storage
ws_id = "tool-attachment-total-prefix"
register_workstream(ws_id)
session = _ready_session(ws_id=ws_id)
raw = b"one immutable tool image"
attachment_id = hashlib.sha256(raw).hexdigest()
data_uri = "data:image/png;base64," + base64.b64encode(raw).decode()
output = [
{"type": "text", "text": "captured image"},
{"type": "image_url", "image_url": {"url": data_uri}},
]
real_atomic_save = session_module.save_tool_message_with_attachments
save_calls = 0
def _commit_then_lose_ack(*args: Any, **kwargs: Any) -> int:
nonlocal save_calls
save_calls += 1
assert real_atomic_save(*args, **kwargs) > 0
return 0
scripted = iter([make_result("", tool_calls=[_TOOL_CALL])])
with contextlib.ExitStack() as stack:
stack.enter_context(
patch.object(session, "_stream_response", side_effect=lambda _gen: next(scripted))
)
stack.enter_context(
patch.object(
session,
"_execute_tools",
return_value=([(_TOOL_CALL["id"], output)], ""),
)
)
stack.enter_context(patch.object(session, "_full_messages", return_value=[]))
stack.enter_context(patch.object(session, "_update_token_table"))
stack.enter_context(patch.object(session, "_print_status_line"))
stack.enter_context(patch.object(session, "_emit_state"))
stack.enter_context(patch.object(session, "_visible_memory_count", return_value=0))
stack.enter_context(patch.object(session, "_apply_post_execute_advisories"))
stack.enter_context(
patch.object(
session,
"_evaluate_output",
side_effect=lambda _call_id, value, *_args, **_kwargs: (value, None),
)
)
stack.enter_context(
patch(
"turnstone.core.session.save_tool_message_with_attachments",
side_effect=_commit_then_lose_ack,
)
)
with pytest.raises(session_module.ConversationPersistenceError):
session.send("capture it")
storage = get_storage()
tool_turns = [
turn
for turn in storage.load_message_turns(session.ws_id, checkpointed=False)
if turn.role.value == "tool"
]
assert save_calls == 1
assert len(tool_turns) == 1
assert tool_turns[0].meta.extra["storage_attachment_ids"] == [attachment_id]
assert storage.get_attachment(attachment_id)["refcount"] == 1
session.capture_history_handoff(
lambda _overscan: storage.load_messages(
session.ws_id,
repair=False,
include_compaction=True,
)
)
assert session.has_unresolved_conversation_persistence() is False
def test_cancelled_partial_lost_ack_is_recoverable_history(tmp_db: Any) -> None:
"""Cancellation cleanup journals its marker before ambiguous durability."""
marker = "partial\n\n[generation cancelled before completion]"
session = _ready_session()
store = _PrefixStore(
ambiguous_when=lambda role, content, _kwargs: role == "assistant" and content == marker
)
def _cancel(_generation: int) -> Any:
session._cancelled_partial_msg = {"role": "assistant", "content": "partial"}
raise session_module.GenerationCancelled()
with (
_send_environment(
session,
[make_result("unused")],
store,
MagicMock(return_value=([], None)),
),
patch.object(session, "_stream_response", side_effect=_cancel),
pytest.raises(session_module.ConversationPersistenceError),
):
session.send("cancel me")
assert session.has_unresolved_conversation_persistence() is True
rows, _token = session.capture_history_handoff(store.snapshot)
assert _roles_and_content(rows)[-1] == ("assistant", marker)
assert rows[-1].get("_commit_key")
assert session.has_unresolved_conversation_persistence() is False
def test_compaction_marker_lost_ack_has_one_success_end_and_one_row(tmp_db: Any) -> None:
"""Checkpoint durability poison does not fabricate a second failed END."""
summary = "accepted compacted prefix"
session = _ready_session()
seed = [
{"role": "user", "content": "old question"},
{"role": "assistant", "content": "old answer"},
]
session.messages = turns_from_dicts(seed)
session._msg_tokens = [2, 2]
store = _PrefixStore(
ambiguous_when=lambda _role, _content, kwargs: (
kwargs.get("source") == session_module.COMPACTION_SOURCE
)
)
store.seed(*seed)
with (
patch.object(
session,
"_summarize_blocks",
return_value=session_module._SummaryResult(text=summary, producer="kernel"),
),
patch.object(session.ui, "on_compaction", side_effect=[41, 42]) as compaction_events,
patch.object(get_storage(), "get_compaction_watermark", return_value=2),
patch("turnstone.core.session.save_message", side_effect=store),
pytest.raises(session_module.ConversationPersistenceError),
):
session._compact_messages()
assert compaction_events.call_count == 2
assert compaction_events.call_args_list[-1].args[0]["ok"] is True
markers = [
row for row in store.snapshot() if row.get("_source") == session_module.COMPACTION_SOURCE
]
assert len(markers) == 1
assert markers[0].get("_commit_key")
rows, _token = session.capture_history_handoff(store.snapshot)
assert len([row for row in rows if row.get("_source") == session_module.COMPACTION_SOURCE]) == 1
assert session.has_unresolved_conversation_persistence() is False
@pytest.mark.parametrize("storage_recovers", [True, False])
def test_soft_close_retries_the_latched_pending_prefix(
tmp_db: Any,
storage_recovers: bool,
) -> None:
"""Close retries safely after 0/0 and unloads only on a complete ACK."""
mgr, adapter, _storage = _make_manager()
adapter.cleanup_ui = MagicMock()
ws = mgr.create(user_id="owner")
session = _ready_session(ws_id=ws.id, user_id="owner")
ws.session = session
ws.ui = session.ui
store = _PrefixStore(ambiguous_when=lambda role, _content, _kwargs: role == "system")
with (
patch("turnstone.core.session.save_message", side_effect=store),
pytest.raises(session_module.ConversationPersistenceError),
):
session._append_system_turn("correction", "close must reconcile me")
if storage_recovers:
store.ambiguous_when = lambda _role, _content, _kwargs: False
with patch("turnstone.core.session.save_message", side_effect=store):
closed = mgr.close(ws.id)
assert closed is storage_recovers
if storage_recovers:
assert mgr.get(ws.id) is None
assert session.has_unresolved_conversation_persistence() is False
assert session._publication_shutdown is True
else:
assert mgr.get(ws.id) is ws
assert ws._closed is False
assert session.has_unresolved_conversation_persistence() is True
assert session._publication_shutdown is False
def test_soft_close_terminal_latch_refuses_a_fresh_worker_claim() -> None:
"""No POST-equivalent dispatch may be acknowledged inside close's latch gap."""
ws_id = "ws-soft-close-dispatch-gap"
mgr, adapter, _storage = _make_manager()
ws = mgr.create(user_id="owner", ws_id=ws_id)
session = _ready_session(ws_id=ws_id, user_id="owner")
ws.session = session
ws.ui = session.ui
adapter.cleanup_ui = MagicMock()
prepared = threading.Event()
release_close = threading.Event()
close_results: list[bool] = []
close_errors: list[BaseException] = []
run_entered = threading.Event()
run_errors: list[BaseException] = []
real_prepare = session.prepare_soft_close
def _gated_prepare() -> bool:
result = real_prepare()
assert result is True
prepared.set()
assert release_close.wait(5), "test did not release soft close"
return result
def _close() -> None:
try:
close_results.append(mgr.close(ws_id))
except BaseException as exc: # pragma: no cover - diagnostic capture
close_errors.append(exc)
def _run_doomed_send() -> None:
run_entered.set()
try:
session.send("must not be acknowledged")
except BaseException as exc:
run_errors.append(exc)
with patch.object(session, "prepare_soft_close", side_effect=_gated_prepare):
closer = threading.Thread(target=_close, daemon=True)
closer.start()
assert prepared.wait(5), "soft close never reached its terminal session latch"
dispatch_ok = session_worker.send(
ws,
enqueue=lambda: None,
run=_run_doomed_send,
thread_name="doomed-soft-close-send",
)
if dispatch_ok:
assert run_entered.wait(5), "acknowledged dispatch never ran"
release_close.set()
closer.join(5)
worker = ws.worker_thread
if worker is not None:
worker.join(5)
assert not closer.is_alive()
assert close_errors == []
assert close_results == [True]
assert dispatch_ok is False
assert run_entered.is_set() is False
assert run_errors == []