Files
turnstone/tests/test_session_worker.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

1248 lines
46 KiB
Python

"""Unit tests for ``turnstone.core.session_worker``.
The shared worker dispatch is load-bearing for both the interactive
``/v1/api/workstreams/{ws_id}/send`` HTTP handler and the coordinator
``CoordinatorAdapter.send`` path. Tests cover the five invariants the
module must hold:
* live worker → enqueue, no thread spawn
* queue.Full → ``False`` (caller surfaces 429)
* concurrent ``send`` calls produce exactly one worker thread
(Stage 1 bug-1 — the racy ``Thread.is_alive()`` gate stays caught)
* ``_worker_running`` cleared in ``finally`` even on uncaught exception
* ownership-clear wake backstop: a worker exiting with USER_DRAIN
nudges queued on an IDLE workstream spawns the wake send that the
IDLE fan-out (which ran on this worker's own thread) had to drop
Callers pass no-arg closures. For a real ChatSession, dispatch captures its
slot-time cancellation witness through a narrow optional method; session
stubs retain the historical callback-only path. The exit backstop only PEEKS
session nudge state defensively (``getattr`` for ``_nudge_queue``, bail on
stubs) — watch-style dispatchers can still drive a session that isn't
installed on ``ws``.
"""
from __future__ import annotations
import collections
import queue
import threading
from typing import Any
import pytest
from tests._helpers import wait_until as _wait_until
from tests._session_helpers import make_session
from turnstone.core import session_worker
from turnstone.core.idle_nudge_watcher import wake_workstream_if_pending
from turnstone.core.nudge_queue import USER_DRAIN, NudgeQueue
from turnstone.core.session import ChatSession
from turnstone.core.session_routes import SessionEndpointConfig, _make_dispatch_attempt
from turnstone.core.workstream import Workstream, WorkstreamState
class _SendSession:
"""ChatSession-shaped stub recording send / queue_message calls."""
def __init__(
self,
*,
queue_full: bool = False,
queue_raises: BaseException | None = None,
send_gate: threading.Event | None = None,
send_raises: BaseException | None = None,
) -> None:
self.send_calls: list[str] = []
self.queue_calls: list[str] = []
self._queue_full = queue_full
self._queue_raises = queue_raises
# Lets a test pin a worker inside ``run`` while a second thread
# races through ``send`` — proves the lock gate (not
# Thread.is_alive) is what serialises them.
self._send_gate = send_gate
self._send_raises = send_raises
def send(self, message: str) -> None:
if self._send_gate is not None:
self._send_gate.wait(timeout=2.0)
if self._send_raises is not None:
raise self._send_raises
self.send_calls.append(message)
def queue_message(self, message: str) -> None:
if self._queue_full:
raise queue.Full
if self._queue_raises is not None:
raise self._queue_raises
self.queue_calls.append(message)
class _ClaimingSendSession(_SendSession):
"""Dispatch stub exposing the production worker-claim protocol."""
def __init__(self) -> None:
super().__init__()
self._cancel_event = threading.Event()
def _capture_worker_claim(self, principal_id: str = "") -> session_worker.WorkerClaim:
return session_worker.WorkerClaim(
session=self,
principal_id=principal_id,
cancel_epoch=0,
cancel_event=self._cancel_event,
cancel_event_was_set=False,
)
def _worker_claim_is_current(self, claim: session_worker.WorkerClaim) -> bool:
return claim.session is self and not claim.cancel_event.is_set()
def queue_message(self, message: str, **_kwargs: Any) -> tuple[str, str, str]:
super().queue_message(message)
return message, "notice", "queued-message"
def _make_ws(session: Any = None) -> Workstream:
ws = Workstream(id="ws-aaaaaaaa", name="ws-aaaa")
ws.session = session # type: ignore[assignment]
return ws
def _send_message(ws: Workstream, session: _SendSession, msg: str) -> bool:
"""Convenience wrapper mirroring the canonical caller shape."""
return session_worker.send(
ws,
enqueue=lambda: session.queue_message(msg),
run=lambda: session.send(msg),
thread_name=f"test-worker-{ws.id[:8]}",
)
def _make_blocked_principal_session(
*, previous_actor: str
) -> tuple[ChatSession, threading.Event, threading.Event, list[tuple[str, str | None]]]:
"""Build the smallest real ``queue_message`` surface for claim-race tests."""
session = ChatSession.__new__(ChatSession)
session._acting_user_id = previous_actor
session._mcp_user_id = "owner"
session._queued_lock = threading.Lock()
session._queued_messages = collections.OrderedDict()
session._retracted_while_popped = set()
session._popped_in_flight = set()
# The worker-slot claim these tests are about is captured from real
# lifecycle state, so seed the fields ``__init__`` would: an open session
# with no cancel edge, no close in flight, and no owed TOOL receipts.
session._generation_lock = threading.RLock()
session._cancel_event = threading.Event()
session._approval_cancel_epoch = 0
session._publication_shutdown = False
session._soft_close_preparing = False
session._tool_structural_debt = None
bind_entered = threading.Event()
release_bind = threading.Event()
ran_as: list[tuple[str, str | None]] = []
def _delayed_bind(user_id: str) -> None:
bind_entered.set()
assert release_bind.wait(5), "test did not release acting-user bind"
session._acting_user_id = user_id
def _record_send(message: str, **_kwargs: Any) -> None:
ran_as.append((message, session._mcp_effective_user_id))
session.bind_acting_user = _delayed_bind # type: ignore[method-assign]
session.send = _record_send # type: ignore[method-assign]
return session, bind_entered, release_bind, ran_as
def _principal_dispatch_attempt(
ws: Workstream,
session: ChatSession,
*,
message: str,
acting_user_id: str,
) -> tuple[bool, dict[str, Any]]:
"""Dispatch through the production HTTP send worker/queue decision."""
cfg = SessionEndpointConfig(
permission_gate=None,
manager_lookup=lambda _request: (None, None),
tenant_check=None,
not_found_label="missing",
audit_action_prefix="workstream",
)
attempt = _make_dispatch_attempt(
ws,
cfg,
ws.ui,
message=message,
resolved_atts=[],
ordered_taken=[],
send_id=f"send-{acting_user_id}",
acting_uid=acting_user_id,
)
return attempt(session)
# ---------------------------------------------------------------------------
# Happy paths
# ---------------------------------------------------------------------------
def test_spawn_worker_runs_target_and_clears_flag() -> None:
session = _SendSession()
ws = _make_ws(session)
ok = _send_message(ws, session, "hello")
assert ok is True
assert ws.worker_thread is not None
ws.worker_thread.join(timeout=2.0)
assert session.send_calls == ["hello"]
assert ws._worker_running is False
def test_reuse_path_when_worker_running_takes_enqueue() -> None:
session = _SendSession()
ws = _make_ws(session)
ws._worker_running = True # simulate a live worker
ok = _send_message(ws, session, "queued")
assert ok is True
# No thread spawned on the reuse path.
assert ws.worker_thread is None
assert session.send_calls == []
assert session.queue_calls == ["queued"]
# Flag stays True — the caller didn't claim ownership.
assert ws._worker_running is True
# ---------------------------------------------------------------------------
# Queue.Full / enqueue failure
# ---------------------------------------------------------------------------
def test_enqueue_queue_full_returns_false_no_spawn() -> None:
session = _SendSession(queue_full=True)
ws = _make_ws(session)
ws._worker_running = True
ok = _send_message(ws, session, "hello")
assert ok is False
assert session.send_calls == []
assert session.queue_calls == []
assert ws.worker_thread is None
# _worker_running unchanged — the live worker still owns it.
assert ws._worker_running is True
def test_enqueue_unexpected_exception_returns_false_logged() -> None:
session = _SendSession(queue_raises=RuntimeError("boom"))
ws = _make_ws(session)
ws._worker_running = True
ok = _send_message(ws, session, "hello")
assert ok is False
assert session.send_calls == []
assert ws.worker_thread is None
assert ws._worker_running is True
def test_send_barrier_active_truth_table() -> None:
"""The two-term order barrier (Workstream.send_barrier_active): list
non-empty OR drain alive. The drain-alive term covers the CLAIMED-
entry window (entry popped, dispatch in flight) — the _PendingSend
invariant "drain not alive ⇒ nothing claimed" is what makes the pair
exhaustive; a one-term copy at any consumer re-opens the wake-jumps-
an-acked-send hole."""
from types import SimpleNamespace
ws = _make_ws()
assert ws.send_barrier_active() is False # empty list, no drain
ws._pending_sends.append(object()) # type: ignore[arg-type]
assert ws.send_barrier_active() is True # list term
ws._pending_drain = SimpleNamespace(is_alive=lambda: True) # type: ignore[assignment]
assert ws.send_barrier_active() is True # both terms
ws._pending_sends.clear()
assert ws.send_barrier_active() is True # drain-alive term alone (claimed window)
ws._pending_drain = SimpleNamespace(is_alive=lambda: False) # type: ignore[assignment]
assert ws.send_barrier_active() is False # dead drain, empty list
ws._pending_drain = None
assert ws.send_barrier_active() is False
def test_spawn_failure_releases_slot_and_reraises(monkeypatch) -> None:
"""``Thread.start`` raising (thread exhaustion, MemoryError) must not
wedge the slot: the claim ``(worker_thread, _worker_running)`` taken
under the lock is rolled back and the exception propagates. Without
the rollback the flag's only clearer is a finally on a thread that
never started — the workstream looks idle forever (no state change
ever fired) while every dispatch takes the reuse path into a queue
no worker will drain, until an operator force-cancel."""
import pytest
session = _SendSession()
ws = _make_ws(session)
class _ExhaustedThread(threading.Thread):
def start(self) -> None:
raise RuntimeError("can't start new thread")
class _ThreadNS:
# Shim only what session_worker.send touches; patching the real
# threading module's Thread attribute would break every other
# test's spawns.
Thread = _ExhaustedThread
current_thread = staticmethod(threading.current_thread)
monkeypatch.setattr(session_worker, "threading", _ThreadNS)
with pytest.raises(RuntimeError, match="can't start new thread"):
_send_message(ws, session, "doomed")
assert ws._worker_running is False
assert ws.worker_thread is None
assert session.send_calls == []
# The slot is genuinely reusable once resources recover.
monkeypatch.setattr(session_worker, "threading", threading)
assert _send_message(ws, session, "recovered") is True
ws.worker_thread.join(timeout=2.0)
assert session.send_calls == ["recovered"]
assert ws._worker_running is False
def test_closed_workstream_refused_no_spawn() -> None:
"""Authoritative closed-check: ``close()`` sets ``_closed`` under
``ws._lock``, so a wake (or send) racing it must be refused HERE —
the wake gate's lockless peek can go stale, and a spawn past this
point would run a full unattended turn (inference, tool calls,
storage writes) on a workstream whose ``ws_closed`` already fired.
"""
session = _SendSession()
ws = _make_ws(session)
ws._closed = True
ok = _send_message(ws, session, "hello")
assert ok is False
assert session.send_calls == []
assert session.queue_calls == []
assert ws.worker_thread is None
assert ws._worker_running is False
def test_closed_workstream_refused_on_reuse_path_too() -> None:
"""The refusal precedes the enqueue branch: no interjection is queued
onto a session whose workstream is already closed."""
session = _SendSession()
ws = _make_ws(session)
ws._worker_running = True
ws._closed = True
ok = _send_message(ws, session, "hello")
assert ok is False
assert session.queue_calls == []
assert ws._worker_running is True # untouched — not ours to clear
# ---------------------------------------------------------------------------
# _worker_running lifecycle
# ---------------------------------------------------------------------------
def test_worker_finally_clears_running_flag_on_exception() -> None:
session = _SendSession(send_raises=RuntimeError("worker-failed"))
ws = _make_ws(session)
ok = _send_message(ws, session, "hello")
assert ok is True
assert ws.worker_thread is not None
ws.worker_thread.join(timeout=2.0)
# Defense-in-depth: even though run() raised, _worker_running is False.
assert ws._worker_running is False
def test_worker_finally_clears_flag_when_run_swallows() -> None:
"""Mirrors the call-site contract: run() catches its own exceptions
for UI surfacing; we still clear the flag in finally."""
session = _SendSession()
ws = _make_ws(session)
captured: list[BaseException] = []
def run() -> None:
try:
session.send("hello")
raise RuntimeError("after-send")
except Exception as exc:
captured.append(exc)
ok = session_worker.send(
ws,
enqueue=lambda: session.queue_message("hello"),
run=run,
)
assert ok is True
assert ws.worker_thread is not None
ws.worker_thread.join(timeout=2.0)
assert isinstance(captured[0], RuntimeError)
assert ws._worker_running is False
# ---------------------------------------------------------------------------
# Concurrency — Stage 1 bug-1 regression
# ---------------------------------------------------------------------------
def test_worker_claim_rejects_previous_actor_before_new_actor_bind_completes() -> None:
"""Queue admission follows the worker claim, never the stale session bind.
A fresh send claims ``_worker_running`` before its worker thread can finish
``bind_acting_user``. If the queue guard reads the prior mutable session
actor in that window, the prior actor can inject text into the new actor's
turn and have it run under the new actor's credentials.
"""
session, bind_entered, release_bind, ran_as = _make_blocked_principal_session(
previous_actor="alice"
)
ws = _make_ws(session)
ws.ui = object() # type: ignore[assignment]
try:
first_ok, first_queue = _principal_dispatch_attempt(
ws,
session,
message="bob starts",
acting_user_id="bob",
)
assert first_ok is True
assert first_queue == {}
assert bind_entered.wait(5), "new worker never entered acting-user bind"
second_ok, second_queue = _principal_dispatch_attempt(
ws,
session,
message="alice must not enter bob's turn",
acting_user_id="alice",
)
assert second_ok is True
assert second_queue == {"rejected": "cross_user_interjection"}
assert session._queued_messages == {}
finally:
release_bind.set()
worker = ws.worker_thread
if worker is not None:
worker.join(timeout=5)
assert ran_as == [("bob starts", "bob")]
def test_worker_claim_accepts_new_actor_followup_before_session_bind_completes() -> None:
"""The claimed actor may queue a follow-up during its own bind window.
Fixing the cross-user hole by merely rejecting every pre-bind enqueue would
turn two rapid sends from the same browser into a false 409. The immutable
worker claim already knows who owns the admitted turn, so that identity is
authoritative for both the allow and deny decisions.
"""
session, bind_entered, release_bind, ran_as = _make_blocked_principal_session(
previous_actor="alice"
)
ws = _make_ws(session)
ws.ui = object() # type: ignore[assignment]
try:
first_ok, first_queue = _principal_dispatch_attempt(
ws,
session,
message="bob starts",
acting_user_id="bob",
)
assert first_ok is True
assert first_queue == {}
assert bind_entered.wait(5), "new worker never entered acting-user bind"
second_ok, second_queue = _principal_dispatch_attempt(
ws,
session,
message="bob follows up",
acting_user_id="bob",
)
assert second_ok is True
assert second_queue == {
"cleaned": "bob follows up",
"priority": "notice",
"msg_id": "send-bob",
}
assert list(session._queued_messages) == ["send-bob"]
finally:
release_bind.set()
worker = ws.worker_thread
if worker is not None:
worker.join(timeout=5)
assert ran_as == [("bob starts", "bob")]
def test_dispatch_refuses_session_swapped_after_caller_capture() -> None:
"""A closure-bound session must be the one the worker claim fences.
The HTTP pending-send drain captures ``ws.session`` before entering the
shared dispatcher. A resume-style object swap can land in that gap. The
dispatcher must refuse both queue and spawn arms instead of capturing a
valid claim for the replacement while its callbacks still mutate the
detached predecessor.
"""
cfg = SessionEndpointConfig(
permission_gate=None,
manager_lookup=lambda _request: (None, None),
tenant_check=None,
not_found_label="missing",
audit_action_prefix="workstream",
)
observed: list[tuple[bool, list[str], list[str]]] = []
for worker_running in (False, True):
captured_session = _ClaimingSendSession()
replacement_session = _ClaimingSendSession()
ws = _make_ws(captured_session)
ws._worker_running = worker_running
attempt = _make_dispatch_attempt(
ws,
cfg,
None,
message="must follow the replacement",
resolved_atts=[],
ordered_taken=[],
send_id="",
acting_uid="",
)
# Exact caller-capture -> dispatcher-capture crossing.
ws.session = replacement_session # type: ignore[assignment]
accepted, _outcome = attempt(captured_session) # type: ignore[arg-type]
owner = ws.worker_thread
if owner is not None:
owner.join(timeout=2)
observed.append(
(
accepted,
list(captured_session.send_calls),
list(captured_session.queue_calls),
)
)
assert observed == [(False, [], []), (False, [], [])]
def test_abandoned_worker_does_not_clear_successor_running_flag() -> None:
"""A force-cancel abandons the worker (``ws.worker_thread`` is cleared /
reassigned to a successor). When the abandoned thread finishes late, its
``finally`` must NOT clear ``_worker_running`` out from under the live
successor — otherwise a third send sees ``_worker_running=False`` and
spawns a second concurrent worker on the same session."""
send_gate = threading.Event()
session = _SendSession(send_gate=send_gate)
ws = _make_ws(session)
ok = _send_message(ws, session, "hello")
assert ok is True
abandoned = ws.worker_thread
assert abandoned is not None
# Simulate force-abandon + a successor send claiming ownership while the
# original worker is still pinned inside run().
sentinel = threading.Thread(target=lambda: None, name="successor")
with ws._lock:
ws.worker_thread = sentinel
ws._worker_running = True
# Release the abandoned worker; it runs its finally.
send_gate.set()
abandoned.join(timeout=3.0)
assert not abandoned.is_alive()
# The successor's ownership is intact — the abandoned worker did not
# clobber the flag or the thread handle.
assert ws._worker_running is True
assert ws.worker_thread is sentinel
def test_abandoned_worker_does_not_clear_successor_principal_claim() -> None:
"""A stale predecessor's finally cannot erase its successor's actor.
Force cancellation permits the successor to claim the slot before the old
thread returns. Principal cleanup therefore needs the same worker-identity
guard as ``_worker_running`` cleanup; a blind clear reopens both false-allow
and false-reject queue decisions during the successor turn.
"""
send_gate = threading.Event()
session = _SendSession(send_gate=send_gate)
ws = _make_ws(session)
assert (
session_worker.send(
ws,
enqueue=lambda: session.queue_message("first"),
run=lambda: session.send("first"),
principal_id="alice",
)
is True
)
abandoned = ws.worker_thread
assert abandoned is not None
assert ws._worker_principal_id == "alice"
sentinel = threading.Thread(target=lambda: None, name="successor")
with ws._lock:
ws.worker_thread = sentinel
ws._worker_running = True
ws._worker_principal_id = "bob"
send_gate.set()
abandoned.join(timeout=3)
assert not abandoned.is_alive()
assert ws.worker_thread is sentinel
assert ws._worker_running is True
assert ws._worker_principal_id == "bob"
def test_force_during_nonabandonable_mutation_keeps_slot_until_owner_exits() -> None:
"""A destructive mutation cannot overlap a force-admitted successor."""
mutation_entered = threading.Event()
release_mutation = threading.Event()
queued = threading.Event()
ws = _make_ws(_SendSession())
def _mutation() -> None:
mutation_entered.set()
assert release_mutation.wait(5), "test did not release mutation"
assert session_worker.send(
ws,
enqueue=queued.set,
run=_mutation,
worker_kind="command",
force_abandonable=False,
thread_name="nonabandonable-mutation",
)
owner = ws.worker_thread
assert owner is not None and mutation_entered.wait(5)
# Exact ownership decision the force-cancel route performs under ws._lock.
with ws._lock:
if ws._worker_force_abandonable:
ws.worker_thread = None
ws._worker_running = False
ws._worker_principal_id = ""
assert ws.worker_thread is owner
assert ws._worker_running is True
assert ws._worker_force_abandonable is False
# A would-be successor takes the existing-slot path; no concurrent worker
# can start while the destructive mutation remains inside its transaction.
assert session_worker.send(ws, enqueue=queued.set, run=lambda: None)
assert queued.is_set()
assert ws.worker_thread is owner
release_mutation.set()
owner.join(5)
assert not owner.is_alive()
assert ws._worker_running is False
assert ws._worker_force_abandonable is True
def test_claim_capture_never_holds_workstream_lock_behind_generation_lock() -> None:
"""Worker admission cannot invert generation -> UI -> workstream order."""
generation_lock = threading.Lock()
generation_held = threading.Event()
capture_attempted = threading.Event()
worker_ran = threading.Event()
commit_acquired_ws: list[bool] = []
dispatch_results: list[bool] = []
class _ClaimSession:
def _capture_worker_claim(self, _principal_id: str = "") -> None:
capture_attempted.set()
with generation_lock:
return None
ws = _make_ws(_ClaimSession())
def _generation_commit() -> None:
with generation_lock:
generation_held.set()
assert capture_attempted.wait(5), "dispatcher never attempted claim capture"
acquired = ws._lock.acquire(timeout=1)
commit_acquired_ws.append(acquired)
if acquired:
ws._lock.release()
def _dispatch() -> None:
dispatch_results.append(
session_worker.send(
ws,
enqueue=lambda: None,
run=worker_ran.set,
thread_name="lock-order-worker",
)
)
commit = threading.Thread(target=_generation_commit, daemon=True)
commit.start()
assert generation_held.wait(5)
dispatcher = threading.Thread(target=_dispatch, daemon=True)
dispatcher.start()
commit.join(5)
dispatcher.join(5)
assert not commit.is_alive() and not dispatcher.is_alive()
assert commit_acquired_ws == [True]
assert dispatch_results == [True]
owner = ws.worker_thread
assert owner is not None
owner.join(5)
assert worker_ran.is_set()
def test_concurrent_send_produces_exactly_one_worker_thread() -> None:
"""Two simultaneous send() calls must land as exactly one worker
spawn and one queued message — not two parallel workers on the
same ChatSession.
The send_gate pins the worker inside session.send while the second
caller races through; the only way the second caller can succeed
is via the enqueue path. If the lock gate were keyed on
Thread.is_alive instead of _worker_running, the loser could spawn
a second worker before the winner reaches session.send.
"""
send_gate = threading.Event()
session = _SendSession(send_gate=send_gate)
ws = _make_ws(session)
results: list[bool] = []
results_lock = threading.Lock()
start_barrier = threading.Barrier(2)
def _caller(msg: str) -> None:
start_barrier.wait(timeout=1.0)
ok = _send_message(ws, session, msg)
with results_lock:
results.append(ok)
t1 = threading.Thread(target=_caller, args=("first",))
t2 = threading.Thread(target=_caller, args=("second",))
t1.start()
t2.start()
t1.join(timeout=3.0)
t2.join(timeout=3.0)
assert not t1.is_alive() and not t2.is_alive()
# At this point session.send is still pinned on send_gate; the
# second caller MUST have taken the enqueue path.
assert len(session.queue_calls) == 1, (
f"expected exactly one queued message; got {session.queue_calls}"
)
# Release the worker, verify final state.
send_gate.set()
assert ws.worker_thread is not None
ws.worker_thread.join(timeout=3.0)
assert results == [True, True]
assert len(session.send_calls) == 1
assert set(session.send_calls + session.queue_calls) == {"first", "second"}
assert ws._worker_running is False
def test_thread_name_default_uses_ws_prefix() -> None:
session = _SendSession()
ws = _make_ws(session)
ok = session_worker.send(
ws,
enqueue=lambda: session.queue_message("hello"),
run=lambda: session.send("hello"),
)
assert ok is True
assert ws.worker_thread is not None
assert ws.worker_thread.name.startswith("session-worker-")
ws.worker_thread.join(timeout=2.0)
def test_thread_name_explicit_override() -> None:
session = _SendSession()
ws = _make_ws(session)
ok = session_worker.send(
ws,
enqueue=lambda: session.queue_message("hello"),
run=lambda: session.send("hello"),
thread_name="custom-name",
)
assert ok is True
assert ws.worker_thread is not None
assert ws.worker_thread.name == "custom-name"
ws.worker_thread.join(timeout=2.0)
class _WakeCapableSession(_SendSession):
"""Adds the ChatSession surface the exit backstop peeks at."""
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._nudge_queue = NudgeQueue()
self.deliver_calls = 0
self.deliver_thread_names: list[str] = []
self.delivered = threading.Event()
def deliver_wake_nudge_from_queue(self) -> None:
# Mirror the real contract: the wake drains its own queue, so
# the wake worker's OWN exit backstop sees nothing pending and
# the chain converges instead of spawning wakes forever.
self.deliver_calls += 1
self.deliver_thread_names.append(threading.current_thread().name)
self._nudge_queue.drain(USER_DRAIN)
self.delivered.set()
class TestWorkerExitWakeBackstop:
"""A worker exiting while its (idle) workstream has USER_DRAIN
nudges queued spawns the wake send the IDLE fan-out had to drop.
Production shape being modelled: ``set_state(IDLE)`` fires its
subscribers on the worker thread from inside ``run()`` —
``CoordinatorIdleObserver`` enqueues ``idle_children``, then
``IdleNudgeWatcher``'s wake dispatch lands on the reuse path
(this very worker still owns the flag) and no-ops. The enqueue
inside ``run`` below stands in for that observer enqueue.
"""
def test_worker_exit_delivers_pending_wake(self) -> None:
session = _WakeCapableSession()
ws = _make_ws(session)
assert ws.state is WorkstreamState.IDLE # dataclass default
def run() -> None:
# What the IDLE fan-out's observer does, on this thread.
session._nudge_queue.enqueue("idle_children", "kids waiting", "any")
ok = session_worker.send(ws, enqueue=lambda: None, run=run)
assert ok is True
# The wake is delivered on a fresh wake-named worker thread…
assert session.delivered.wait(timeout=2.0), (
"exit backstop did not deliver the pending nudge"
)
assert session.deliver_thread_names[0].startswith("wake-nudge-")
# …after which the wake worker's own exit backstop sees an empty
# queue and the chain converges: flag at rest, exactly one deliver.
_wait_until(lambda: ws._worker_running is False)
assert session.deliver_calls == 1
assert len(session._nudge_queue) == 0
def test_worker_exit_no_wake_when_queue_empty(self) -> None:
session = _WakeCapableSession()
ws = _make_ws(session)
ok = session_worker.send(ws, enqueue=lambda: None, run=lambda: None)
assert ok is True
original = ws.worker_thread
assert original is not None
original.join(timeout=2.0)
assert ws.worker_thread is original # no wake spawned
assert session.deliver_calls == 0
assert ws._worker_running is False
def test_worker_exit_no_wake_for_stub_session_without_queue(self) -> None:
"""The narrow-contract escape hatch: a session without a
``_nudge_queue`` (watch-style stubs) is skipped by the shared
wake gate's own defensive peek — no AttributeError, no wake."""
session = _SendSession()
ws = _make_ws(session)
ok = _send_message(ws, session, "hello")
assert ok is True
original = ws.worker_thread
assert original is not None
original.join(timeout=2.0)
assert ws.worker_thread is original
assert ws._worker_running is False
def test_worker_exit_no_wake_when_state_not_idle(self) -> None:
"""An ERROR exit stays parked for the operator — pending nudges
wait for the next real interaction rather than burning
unattended inference on a failed session."""
session = _WakeCapableSession()
ws = _make_ws(session)
def run() -> None:
session._nudge_queue.enqueue("idle_children", "kids waiting", "any")
ws.state = WorkstreamState.ERROR
ok = session_worker.send(ws, enqueue=lambda: None, run=run)
assert ok is True
original = ws.worker_thread
assert original is not None
original.join(timeout=2.0)
assert ws.worker_thread is original
assert session.deliver_calls == 0
assert len(session._nudge_queue) == 1 # still queued for later seams
def test_abandoned_worker_does_not_run_wake_backstop(self) -> None:
"""Only the owner retries: an abandoned worker (successor claimed
the flag) finishing late must not spawn a wake — the successor's
own exit runs the backstop."""
send_gate = threading.Event()
session = _WakeCapableSession(send_gate=send_gate)
ws = _make_ws(session)
ok = _send_message(ws, session, "hello")
assert ok is True
abandoned = ws.worker_thread
assert abandoned is not None
session._nudge_queue.enqueue("idle_children", "kids waiting", "any")
sentinel = threading.Thread(target=lambda: None, name="successor")
with ws._lock:
ws.worker_thread = sentinel
ws._worker_running = True
send_gate.set()
abandoned.join(timeout=3.0)
assert not abandoned.is_alive()
# No wake spawned by the abandoned thread; ownership intact.
assert ws.worker_thread is sentinel
assert session.deliver_calls == 0
assert ws._worker_running is True
class TestWorkerExitInterjectionBackstop:
"""The ownership-clear seam cannot strand a just-accepted interjection."""
@staticmethod
def _session() -> ChatSession:
session = make_session(user_id="alice")
session._acting_user_id = "alice"
return session
def test_enqueue_after_last_flush_hands_off_same_principal_and_correlation(
self,
) -> None:
"""A reuse enqueue at run's tail is delivered by a successor wake."""
session = self._session()
ws = _make_ws(session)
delivered = threading.Event()
calls: list[tuple[str, tuple[str, ...], str]] = []
def _record_send(message: str, *, client_send_ids: tuple[str, ...] = ()) -> None:
claim = session_worker.current_worker_claim(session)
assert claim is not None
calls.append((message, client_send_ids, claim.principal_id))
delivered.set()
session.send = _record_send # type: ignore[method-assign]
def _outgoing_run() -> None:
# This nested dispatch is the deterministic equivalent of another
# request winning ws._lock after ChatSession's final queue flush
# but before this runner enters its ownership-clear finally.
assert session_worker.send(
ws,
enqueue=lambda: session.queue_message(
"late follow-up",
queue_msg_id="late-message",
interjector_user_id="alice",
turn_principal_id="alice",
client_send_id="client-late",
),
run=lambda: pytest.fail("reuse dispatch spawned a second worker"),
expected_session=session,
principal_id="alice",
)
assert len(session._nudge_queue) == 0
assert session_worker.send(
ws,
enqueue=lambda: None,
run=_outgoing_run,
expected_session=session,
principal_id="alice",
)
assert delivered.wait(5), "worker-exit handoff did not deliver the queued row"
_wait_until(lambda: ws._worker_running is False)
assert calls == [("late follow-up", ("client-late",), "alice")]
assert session._queued_messages == {}
assert len(session._nudge_queue) == 0
def test_non_exit_gate_remains_nudge_only(self) -> None:
session = self._session()
ws = _make_ws(session)
session.queue_message(
"wait for owner clear",
queue_msg_id="nudge-only-control",
interjector_user_id="alice",
turn_principal_id="alice",
)
assert wake_workstream_if_pending(ws, trigger="idle-transition") is False
assert list(session._queued_messages) == ["nudge-only-control"]
assert ws._worker_running is False
def test_retraction_before_wake_pop_converges_without_send(self) -> None:
session = self._session()
ws = _make_ws(session)
session.queue_message(
"retract me",
queue_msg_id="retracted-message",
interjector_user_id="alice",
turn_principal_id="alice",
)
pop_entered = threading.Event()
release_pop = threading.Event()
real_pop = session._pop_queued_messages
send_calls: list[str] = []
def _blocked_pop(**kwargs: Any) -> Any:
# kwargs passthrough so signature growth never narrows the shim.
pop_entered.set()
assert release_pop.wait(5), "test did not release queue pop"
return real_pop(**kwargs)
session._pop_queued_messages = _blocked_pop # type: ignore[method-assign]
session.send = lambda message, **_kwargs: send_calls.append(message) # type: ignore[method-assign]
assert wake_workstream_if_pending(
ws,
trigger="test-worker-exit",
include_interjections=True,
)
assert pop_entered.wait(5), "wake worker never reached the queue pop"
assert session.dequeue_message("retracted-message") is True
release_pop.set()
_wait_until(lambda: ws._worker_running is False)
assert send_calls == []
assert session._queued_messages == {}
assert (
wake_workstream_if_pending(
ws,
trigger="test-worker-exit",
include_interjections=True,
)
is False
)
def test_empty_interjection_is_discarded_without_a_wake_loop(self) -> None:
session = self._session()
ws = _make_ws(session)
cleaned, _priority, _msg_id = session.queue_message(
"!!!",
queue_msg_id="empty-message",
interjector_user_id="alice",
turn_principal_id="alice",
)
send_calls: list[str] = []
session.send = lambda message, **_kwargs: send_calls.append(message) # type: ignore[method-assign]
assert cleaned == ""
assert wake_workstream_if_pending(
ws,
trigger="test-worker-exit",
include_interjections=True,
)
_wait_until(lambda: ws._worker_running is False)
assert send_calls == []
assert session._queued_messages == {}
def test_restored_failed_wake_is_retained_without_retry_loop(self) -> None:
session = self._session()
ws = _make_ws(session)
session.queue_message(
"keep after failure",
queue_msg_id="restored-message",
interjector_user_id="alice",
turn_principal_id="alice",
client_send_id="client-restored",
)
attempts: list[str] = []
def _fail_before_append(message: str, **_kwargs: Any) -> None:
attempts.append(message)
raise RuntimeError("injected preamble failure")
session.send = _fail_before_append # type: ignore[method-assign]
assert wake_workstream_if_pending(
ws,
trigger="test-worker-exit",
include_interjections=True,
)
wake_worker = ws.worker_thread
assert wake_worker is not None
_wait_until(lambda: ws._worker_running is False)
assert attempts == ["keep after failure"]
assert list(session._queued_messages) == ["restored-message"]
# The failed queue-only wake's own exit carried the exact snapshot
# token and suppressed a retry. No successor worker was spawned.
assert ws.worker_thread is wake_worker
assert attempts == ["keep after failure"]
def test_deferred_wake_token_does_not_suppress_competing_worker_exit(self) -> None:
"""Only a spawned wake owns the snapshot exclusion token.
A same-principal worker can claim the slot after the queue predicate
but before the wake dispatch. The wake then takes the reuse/no-op arm;
if that pre-spawn snapshot were sticky, the competing worker's exit
could not recover the older queued row after failing before its drain.
"""
session = self._session()
ws = _make_ws(session)
session.queue_message(
"older stranded row",
queue_msg_id="older-row",
interjector_user_id="alice",
turn_principal_id="alice",
client_send_id="client-older",
)
competing_started = threading.Event()
release_competing = threading.Event()
delivered = threading.Event()
calls: list[tuple[str, tuple[str, ...], str]] = []
def _record_send(message: str, *, client_send_ids: tuple[str, ...] = ()) -> None:
claim = session_worker.current_worker_claim(session)
assert claim is not None
calls.append((message, client_send_ids, claim.principal_id))
delivered.set()
session.send = _record_send # type: ignore[method-assign]
real_claim = session.claim_pending_interjection_wake
injected_competitor = False
def _claim_then_compete(*, exclude_signature: object | None = None) -> Any:
nonlocal injected_competitor
signature = real_claim(exclude_signature=exclude_signature)
if signature is not None and not injected_competitor:
injected_competitor = True
def _competing_run() -> None:
competing_started.set()
assert release_competing.wait(5), "test did not release competing worker"
# Return before touching the retained queue: this models a
# fresh worker failing in its pre-drain setup.
assert session_worker.send(
ws,
enqueue=lambda: pytest.fail("competitor unexpectedly reused a worker"),
run=_competing_run,
expected_session=session,
principal_id="alice",
)
assert competing_started.wait(5), "competing worker did not claim the slot"
return signature
session.claim_pending_interjection_wake = _claim_then_compete # type: ignore[method-assign]
# The queue-only wake loses the slot race and takes its no-op enqueue
# arm. It must not leave suppression behind for the real owner.
assert wake_workstream_if_pending(
ws,
trigger="test-worker-exit",
include_interjections=True,
)
assert calls == []
assert list(session._queued_messages) == ["older-row"]
release_competing.set()
assert delivered.wait(5), "competing worker exit did not retry the older row"
_wait_until(lambda: ws._worker_running is False)
assert calls == [("older stranded row", ("client-older",), "alice")]
assert session._queued_messages == {}
@pytest.mark.parametrize(
"blocker",
["budget", "abandoned", "unresolved", "foreign_principal", "gone"],
)
def test_queue_only_wake_refuses_unattended_retry_blockers(self, blocker: str) -> None:
session = self._session()
ws = _make_ws(session)
owner = "bob" if blocker == "foreign_principal" else "alice"
session.queue_message(
"wait for an explicit seam",
queue_msg_id=f"blocked-{blocker}",
interjector_user_id=owner,
turn_principal_id=owner,
)
if blocker == "budget":
session._budget_exhausted = True
elif blocker == "abandoned":
session._generation_abandoned = True
elif blocker == "unresolved":
session.has_unresolved_conversation_persistence = lambda: True # type: ignore[method-assign]
elif blocker == "gone":
# The terminal hard-delete latch: the gone discovery clears the
# pending-commit journal, so the unresolved-persistence blocker
# reads False exactly when no turn can ever land — the latch
# needs its own refusal arm or the wake spawns doomed.
session._workstream_gone_ws = session._ws_id
assert (
wake_workstream_if_pending(
ws,
trigger="test-worker-exit",
include_interjections=True,
)
is False
)
assert list(session._queued_messages) == [f"blocked-{blocker}"]
assert ws._worker_running is False
def test_interjection_claim_itself_refuses_a_gone_workstream(self) -> None:
"""The claim gate carries its own gone arm — it must refuse for ANY
caller, not only behind the watcher's spawn gate (a future caller
that consults the claim directly gets the same fail-closed answer)."""
session = self._session()
session.queue_message(
"never wake for this",
queue_msg_id="gone-q",
interjector_user_id="alice",
turn_principal_id="alice",
)
session._workstream_gone_ws = session._ws_id
assert session.claim_pending_interjection_wake() is None
assert list(session._queued_messages) == ["gone-q"]
def test_does_not_deadlock_when_run_briefly_grabs_ws_lock() -> None:
"""Sanity check: ``run`` is invoked OUTSIDE ``ws._lock``. A worker
body that briefly takes the lock (e.g. to update worker state)
must not deadlock with the dispatch path."""
session = _SendSession()
ws = _make_ws(session)
def run() -> None:
with ws._lock:
pass # would deadlock if dispatch held the lock here
session.send("hello")
ok = session_worker.send(
ws,
enqueue=lambda: session.queue_message("hello"),
run=run,
)
assert ok is True
assert ws.worker_thread is not None
ws.worker_thread.join(timeout=2.0)
assert session.send_calls == ["hello"]
assert ws._worker_running is False