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
476 lines
17 KiB
Python
476 lines
17 KiB
Python
"""Adversarial worker/terminal admission races for ``ChatSession``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tests._session_helpers import make_session
|
|
from turnstone.core import session_worker
|
|
from turnstone.core.session import ConversationPersistenceError, GenerationCancelled
|
|
from turnstone.core.trajectory import turn_to_dict
|
|
from turnstone.core.workstream import Workstream
|
|
|
|
|
|
class _ObservedRLock:
|
|
"""Reentrant lock that exposes one named thread's acquisition attempt."""
|
|
|
|
def __init__(self, observed_thread: str) -> None:
|
|
self._lock = threading.RLock()
|
|
self._observed_thread = observed_thread
|
|
self.acquire_attempted = threading.Event()
|
|
|
|
def acquire(self, *args: Any, **kwargs: Any) -> bool:
|
|
if threading.current_thread().name == self._observed_thread:
|
|
self.acquire_attempted.set()
|
|
return self._lock.acquire(*args, **kwargs)
|
|
|
|
def release(self) -> None:
|
|
self._lock.release()
|
|
|
|
def __enter__(self) -> _ObservedRLock:
|
|
self.acquire()
|
|
return self
|
|
|
|
def __exit__(self, *_args: Any) -> None:
|
|
self.release()
|
|
|
|
|
|
def _workstream(session: Any) -> Workstream:
|
|
ws = Workstream(id="ws-lifecycle-admission", name="lifecycle-admission", user_id="owner")
|
|
ws.session = session
|
|
ws.ui = session.ui
|
|
return ws
|
|
|
|
|
|
def test_stop_invalidates_worker_slot_before_delayed_send_entry(tmp_db: Any) -> None:
|
|
"""A Stop cannot be erased by a worker that has not entered ``send`` yet."""
|
|
|
|
session = make_session(user_id="owner", ws_id="ws-lifecycle-admission")
|
|
ws = _workstream(session)
|
|
worker_entered = threading.Event()
|
|
release_worker = threading.Event()
|
|
errors: list[BaseException] = []
|
|
refresh = MagicMock()
|
|
|
|
def _run() -> None:
|
|
worker_entered.set()
|
|
assert release_worker.wait(5), "test did not release delayed worker"
|
|
try:
|
|
# Mirrors the production wrapper. The bind is intentionally before
|
|
# send; the active WorkerClaim makes it defer until generation claim.
|
|
session.bind_acting_user("alice")
|
|
session.send("must remain stopped")
|
|
except BaseException as exc:
|
|
errors.append(exc)
|
|
|
|
with patch.object(session, "_refresh_model_from_registry", refresh):
|
|
assert session_worker.send(
|
|
ws,
|
|
enqueue=lambda: None,
|
|
run=_run,
|
|
principal_id="alice",
|
|
thread_name="delayed-stopped-worker",
|
|
)
|
|
worker = ws.worker_thread
|
|
assert worker is not None and worker_entered.wait(5)
|
|
session.cancel()
|
|
release_worker.set()
|
|
worker.join(5)
|
|
|
|
assert not worker.is_alive()
|
|
assert len(errors) == 1 and isinstance(errors[0], GenerationCancelled)
|
|
assert session._generation == 0
|
|
assert session.messages == []
|
|
refresh.assert_not_called()
|
|
|
|
|
|
def test_stop_during_pre_generation_registry_refresh_reaches_no_turn_admission(tmp_db: Any) -> None:
|
|
"""Registry work now follows claim, so Stop remains visible when it returns."""
|
|
|
|
session = make_session(user_id="owner", ws_id="ws-lifecycle-admission")
|
|
ws = _workstream(session)
|
|
refresh_entered = threading.Event()
|
|
release_refresh = threading.Event()
|
|
errors: list[BaseException] = []
|
|
session.ui.on_state_change = MagicMock() # type: ignore[attr-defined,method-assign]
|
|
|
|
def _blocked_refresh() -> None:
|
|
refresh_entered.set()
|
|
assert release_refresh.wait(5), "test did not release registry refresh"
|
|
|
|
def _run() -> None:
|
|
try:
|
|
session.bind_acting_user("alice")
|
|
session.send("stop before user admission")
|
|
except BaseException as exc:
|
|
errors.append(exc)
|
|
|
|
with patch.object(session, "_refresh_model_from_registry", side_effect=_blocked_refresh):
|
|
assert session_worker.send(
|
|
ws,
|
|
enqueue=lambda: None,
|
|
run=_run,
|
|
principal_id="alice",
|
|
thread_name="registry-refresh-worker",
|
|
)
|
|
worker = ws.worker_thread
|
|
assert worker is not None and refresh_entered.wait(5)
|
|
session.cancel()
|
|
release_refresh.set()
|
|
worker.join(5)
|
|
|
|
assert not worker.is_alive()
|
|
assert errors == [] # send handles a cancellation after generation claim
|
|
assert session._generation == 1
|
|
assert session.messages == []
|
|
assert session.has_unresolved_conversation_persistence() is False
|
|
|
|
|
|
def test_force_successor_keeps_generation_actor_and_worker_slot_from_delayed_predecessor(
|
|
tmp_db: Any,
|
|
) -> None:
|
|
"""A force-abandoned delayed thread cannot supersede its live successor."""
|
|
|
|
session = make_session(user_id="owner", ws_id="ws-lifecycle-admission")
|
|
ws = _workstream(session)
|
|
predecessor_entered = threading.Event()
|
|
release_predecessor = threading.Event()
|
|
successor_claimed = threading.Event()
|
|
release_successor = threading.Event()
|
|
predecessor_errors: list[BaseException] = []
|
|
successor_generations: list[int] = []
|
|
refresh = MagicMock()
|
|
|
|
def _predecessor() -> None:
|
|
predecessor_entered.set()
|
|
assert release_predecessor.wait(5), "test did not release predecessor"
|
|
try:
|
|
session.bind_acting_user("alice")
|
|
session.send("stale predecessor")
|
|
except BaseException as exc:
|
|
predecessor_errors.append(exc)
|
|
|
|
def _successor() -> None:
|
|
claim = session_worker.current_worker_claim(session)
|
|
assert claim is not None
|
|
generation = session._claim_generation(
|
|
principal_id=claim.principal_id,
|
|
expected_cancel_epoch=claim.cancel_epoch,
|
|
)
|
|
successor_generations.append(generation)
|
|
session._bind_acting_user_for_generation("bob", generation)
|
|
successor_claimed.set()
|
|
assert release_successor.wait(5), "test did not release successor"
|
|
session._generation_principals.pop(generation, None)
|
|
session._consume_cancel(generation)
|
|
|
|
with patch.object(session, "_refresh_model_from_registry", refresh):
|
|
assert session_worker.send(
|
|
ws,
|
|
enqueue=lambda: None,
|
|
run=_predecessor,
|
|
principal_id="alice",
|
|
thread_name="force-predecessor",
|
|
)
|
|
predecessor_thread = ws.worker_thread
|
|
assert predecessor_thread is not None and predecessor_entered.wait(5)
|
|
|
|
session.cancel()
|
|
# Exact force ownership transition from the HTTP cancel path.
|
|
with ws._lock:
|
|
ws.worker_thread = None
|
|
ws._worker_running = False
|
|
ws._worker_principal_id = ""
|
|
|
|
assert session_worker.send(
|
|
ws,
|
|
enqueue=lambda: None,
|
|
run=_successor,
|
|
principal_id="bob",
|
|
thread_name="force-successor",
|
|
)
|
|
successor_thread = ws.worker_thread
|
|
assert successor_thread is not None and successor_claimed.wait(5)
|
|
|
|
release_predecessor.set()
|
|
predecessor_thread.join(5)
|
|
|
|
assert successor_thread.is_alive()
|
|
assert ws.worker_thread is successor_thread
|
|
assert ws._worker_running is True
|
|
assert ws._worker_principal_id == "bob"
|
|
assert session._generation == 1
|
|
assert session._mcp_effective_user_id == "bob"
|
|
|
|
release_successor.set()
|
|
successor_thread.join(5)
|
|
|
|
assert successor_generations == [1]
|
|
assert len(predecessor_errors) == 1
|
|
assert isinstance(predecessor_errors[0], GenerationCancelled)
|
|
assert session._generation == 1
|
|
assert session._mcp_effective_user_id == "bob"
|
|
refresh.assert_not_called()
|
|
|
|
|
|
def test_refused_soft_close_invalidates_old_slot_but_allows_fresh_worker(tmp_db: Any) -> None:
|
|
"""Rollback reopens admission without resurrecting a pre-close worker."""
|
|
|
|
session = make_session(user_id="owner", ws_id="ws-lifecycle-admission")
|
|
ws = _workstream(session)
|
|
old_entered = threading.Event()
|
|
release_old = threading.Event()
|
|
old_errors: list[BaseException] = []
|
|
fresh_generations: list[int] = []
|
|
refresh = MagicMock()
|
|
|
|
def _old_worker() -> None:
|
|
old_entered.set()
|
|
assert release_old.wait(5), "test did not release old worker"
|
|
try:
|
|
session.send("pre-close worker")
|
|
except BaseException as exc:
|
|
old_errors.append(exc)
|
|
|
|
def _fresh_worker() -> None:
|
|
claim = session_worker.current_worker_claim(session)
|
|
assert claim is not None
|
|
fresh_generations.append(
|
|
session._claim_generation(
|
|
principal_id=claim.principal_id,
|
|
expected_cancel_epoch=claim.cancel_epoch,
|
|
)
|
|
)
|
|
|
|
with patch.object(session, "_refresh_model_from_registry", refresh):
|
|
assert session_worker.send(ws, enqueue=lambda: None, run=_old_worker)
|
|
old_thread = ws.worker_thread
|
|
assert old_thread is not None and old_entered.wait(5)
|
|
|
|
before_epoch = session._approval_cancel_epoch
|
|
with patch.object(
|
|
session,
|
|
"_reconcile_pending_conversation_commits",
|
|
side_effect=ConversationPersistenceError("still unavailable"),
|
|
):
|
|
assert session.prepare_soft_close() is False
|
|
assert session._publication_shutdown is False
|
|
assert session._approval_cancel_epoch == before_epoch + 1
|
|
|
|
release_old.set()
|
|
old_thread.join(5)
|
|
assert len(old_errors) == 1 and isinstance(old_errors[0], GenerationCancelled)
|
|
assert session._generation == 1 # rollback bump only; old slot did not claim
|
|
|
|
assert session_worker.send(ws, enqueue=lambda: None, run=_fresh_worker)
|
|
fresh_thread = ws.worker_thread
|
|
assert fresh_thread is not None
|
|
fresh_thread.join(5)
|
|
|
|
assert fresh_generations == [2]
|
|
refresh.assert_not_called()
|
|
|
|
|
|
def _terminal_action(session: Any, kind: str) -> bool:
|
|
if kind == "soft":
|
|
return bool(session.prepare_soft_close())
|
|
session.shutdown_publication_and_drain_durability()
|
|
return True
|
|
|
|
|
|
@pytest.mark.parametrize("terminal_kind", ["soft", "hard"])
|
|
def test_terminal_admission_wins_before_direct_conversation_mutation(
|
|
tmp_db: Any,
|
|
terminal_kind: str,
|
|
) -> None:
|
|
"""A direct row that loses terminal admission mutates no surface."""
|
|
|
|
session = make_session(user_id="owner", ws_id="ws-lifecycle-admission")
|
|
prepare_entered = threading.Event()
|
|
release_prepare = threading.Event()
|
|
errors: list[BaseException] = []
|
|
original_prepare = session._prepare_direct_conversation_mutation
|
|
|
|
def _blocked_prepare(deferred: Any) -> None:
|
|
prepare_entered.set()
|
|
assert release_prepare.wait(5), "test did not release direct preparation"
|
|
original_prepare(deferred)
|
|
|
|
def _append() -> None:
|
|
try:
|
|
session._append_system_turn("correction", "must not cross terminal admission")
|
|
except BaseException as exc:
|
|
errors.append(exc)
|
|
|
|
with (
|
|
patch.object(
|
|
session,
|
|
"_prepare_direct_conversation_mutation",
|
|
side_effect=_blocked_prepare,
|
|
),
|
|
patch("turnstone.core.session.save_message", return_value=1) as save,
|
|
):
|
|
mutator = threading.Thread(target=_append, daemon=True)
|
|
mutator.start()
|
|
assert prepare_entered.wait(5)
|
|
assert _terminal_action(session, terminal_kind) is True
|
|
release_prepare.set()
|
|
mutator.join(5)
|
|
|
|
assert not mutator.is_alive()
|
|
assert len(errors) == 1 and isinstance(errors[0], RuntimeError)
|
|
assert session.messages == []
|
|
assert session.has_unresolved_conversation_persistence() is False
|
|
save.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("terminal_kind", ["soft", "hard"])
|
|
def test_direct_conversation_mutation_admission_makes_terminal_wait_for_ack(
|
|
tmp_db: Any,
|
|
terminal_kind: str,
|
|
) -> None:
|
|
"""Once a direct row is admitted, terminal handoff drains its ticket."""
|
|
|
|
session = make_session(user_id="owner", ws_id="ws-lifecycle-admission")
|
|
save_entered = threading.Event()
|
|
release_save = threading.Event()
|
|
terminal_done = threading.Event()
|
|
errors: list[BaseException] = []
|
|
terminal_results: list[bool] = []
|
|
|
|
def _blocked_save(*_args: Any, **_kwargs: Any) -> int:
|
|
save_entered.set()
|
|
assert release_save.wait(5), "test did not release durable ACK"
|
|
return 1
|
|
|
|
def _append() -> None:
|
|
try:
|
|
session._append_system_turn("correction", "admitted before terminal handoff")
|
|
except BaseException as exc:
|
|
errors.append(exc)
|
|
|
|
def _terminate() -> None:
|
|
try:
|
|
terminal_results.append(_terminal_action(session, terminal_kind))
|
|
finally:
|
|
terminal_done.set()
|
|
|
|
with patch("turnstone.core.session.save_message", side_effect=_blocked_save) as save:
|
|
mutator = threading.Thread(target=_append, daemon=True)
|
|
mutator.start()
|
|
assert save_entered.wait(5)
|
|
|
|
terminal = threading.Thread(target=_terminate, daemon=True)
|
|
terminal.start()
|
|
assert terminal_done.wait(0.1) is False
|
|
|
|
release_save.set()
|
|
mutator.join(5)
|
|
terminal.join(5)
|
|
|
|
assert not mutator.is_alive() and not terminal.is_alive()
|
|
assert errors == []
|
|
assert terminal_results == [True]
|
|
assert save.call_count == 1
|
|
assert turn_to_dict(session.messages[-1]) == {
|
|
"role": "system",
|
|
"content": "admitted before terminal handoff",
|
|
"_source": "correction",
|
|
}
|
|
assert session.has_unresolved_conversation_persistence() is False
|
|
|
|
|
|
def test_hard_delete_barrier_waits_for_started_ambiguous_ack_reconciliation(
|
|
tmp_db: Any,
|
|
) -> None:
|
|
"""A repair that wins visibility must ACK before hard deletion can run."""
|
|
|
|
session = make_session(user_id="owner", ws_id="ws-lifecycle-admission")
|
|
with (
|
|
patch("turnstone.core.session.save_message", return_value=0),
|
|
pytest.raises(ConversationPersistenceError),
|
|
):
|
|
session._append_system_turn("correction", "ambiguous predecessor")
|
|
assert session.has_unresolved_conversation_persistence() is True
|
|
# Admit this explicit repair attempt; ordinary pre-due mutations now fail
|
|
# fast without a storage call so user activity cannot bypass backoff.
|
|
session._conversation_persistence_next_retry_at = 0.0
|
|
|
|
visibility = _ObservedRLock("hard-terminal")
|
|
session._history_visibility_lock = visibility # type: ignore[assignment]
|
|
repair_entered = threading.Event()
|
|
release_repair = threading.Event()
|
|
terminal_done = threading.Event()
|
|
durable_deleted = threading.Event()
|
|
mutation_errors: list[BaseException] = []
|
|
|
|
def _blocked_repair(*_args: Any, **_kwargs: Any) -> int:
|
|
repair_entered.set()
|
|
assert release_repair.wait(5), "test did not release reconciliation"
|
|
# This is the storage safety property the hard-delete caller relies on:
|
|
# its delete step cannot follow the drain primitive until this save ACKs.
|
|
assert not durable_deleted.is_set()
|
|
return 41
|
|
|
|
def _append_suffix() -> None:
|
|
try:
|
|
session._append_system_turn("correction", "must lose terminal admission")
|
|
except BaseException as exc:
|
|
mutation_errors.append(exc)
|
|
|
|
def _hard_terminal() -> None:
|
|
session.shutdown_publication_and_drain_durability()
|
|
durable_deleted.set() # stands in for the caller's immediately following delete
|
|
terminal_done.set()
|
|
|
|
with patch("turnstone.core.session.save_message", side_effect=_blocked_repair) as save:
|
|
mutator = threading.Thread(target=_append_suffix, daemon=True, name="direct-repair")
|
|
mutator.start()
|
|
assert repair_entered.wait(5)
|
|
|
|
terminal = threading.Thread(target=_hard_terminal, daemon=True, name="hard-terminal")
|
|
terminal.start()
|
|
assert visibility.acquire_attempted.wait(5)
|
|
assert terminal_done.is_set() is False
|
|
|
|
release_repair.set()
|
|
mutator.join(5)
|
|
terminal.join(5)
|
|
|
|
assert not mutator.is_alive() and not terminal.is_alive()
|
|
assert durable_deleted.is_set()
|
|
assert save.call_count == 1
|
|
assert len(mutation_errors) == 1 and isinstance(mutation_errors[0], RuntimeError)
|
|
assert [turn_to_dict(turn)["content"] for turn in session.messages] == ["ambiguous predecessor"]
|
|
assert session.has_unresolved_conversation_persistence() is False
|
|
|
|
|
|
def test_hard_delete_barrier_rejects_late_ambiguous_ack_reconciliation(
|
|
tmp_db: Any,
|
|
) -> None:
|
|
"""A repair that loses the terminal latch performs no storage write."""
|
|
|
|
session = make_session(user_id="owner", ws_id="ws-lifecycle-admission")
|
|
with (
|
|
patch("turnstone.core.session.save_message", return_value=0),
|
|
pytest.raises(ConversationPersistenceError),
|
|
):
|
|
session._append_system_turn("correction", "ambiguous predecessor")
|
|
assert session.has_unresolved_conversation_persistence() is True
|
|
|
|
session.shutdown_publication_and_drain_durability()
|
|
|
|
with (
|
|
patch("turnstone.core.session.save_message", return_value=42) as save,
|
|
pytest.raises(RuntimeError, match="closed session"),
|
|
):
|
|
session._append_system_turn("correction", "late suffix")
|
|
|
|
save.assert_not_called()
|
|
assert [turn_to_dict(turn)["content"] for turn in session.messages] == ["ambiguous predecessor"]
|
|
assert session.has_unresolved_conversation_persistence() is True
|