mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
1224b02d03
Eight rounds of findings against the defer-and-drain seam shared one
generator: N sites each hand-copying M obligations (spawn discipline,
the order-barrier pair, backpressure, best-effort emission, the client
settle matrix), with every review finding an empty (site x obligation)
cell. This round makes each obligation a single primitive:
- The order barrier is Workstream.send_barrier_active() — one
definition of the two-term pair (pending entries OR drain alive),
consulted by the /send route, the coordinator adapter, and the
queued-nudge wake gate, which previously carried only the list term
and let a synthetic wake jump an acknowledged send during the
claimed-entry window. _PendingSend moved to workstream.py beside the
invariant that justifies the drain-alive term; the pending fields got
precise types and worker_kind became a Literal, so a typo'd
"command" comparison is now a type error instead of a silently
never-firing defer guard.
- _defer_send probes the barrier before constructing anything, bounds
acceptance at 10 pending (the interjection queue's own backpressure
contract — unbounded acceptance pinned message + attachment bytes
per entry for a whole command window and then ran one unattended
turn each), and spawns the drain with rollback: a Thread.start
failure pops the just-accepted entry and answers the retryable
queue_full instead of 500ing after registration (a phantom the
client could neither see nor retract, dispatched later as duplicate
turns). start() deliberately stays inside the lock, unlike
session_worker's outside-lock discipline: this slot is
is_alive()-gated, false for a constructed-but-unstarted thread, so
an outside-lock start would open a double-drain window.
- A /command whose worker never spawned answers 503
{"status": "error"} (spec + docs + a pane error arm) instead of the
generic 200 ok that told SDK callers their /clear ran.
- The compaction lifecycle emitter is raise-proof at its single
dispatch tail: a raising duck-typed hook degrades to a lost render,
never a lost end event — previously a raising on_error or a raising
failed-end emit left every pane a frozen progress bar, and a raising
SUCCESS end after the committed swap fabricated a failed end.
- The client settle matrix lives once: composer_queue's
settleSendResponse owns every /send response arm for both panes
(the near-verbatim twins were already drifting), parsePriority is
shared, and the busy stamp is centralized in setBusy(b, source) with
"server" as the fail-safe default. Deferred sends release the
composer (no worker exists for them; retracting the chip no longer
strands the pane in Stop mode), queue_full on an idle-looking pane
removes the optimistic bubble and restores busy (the refusal can now
fire with no worker and no drain to ever emit a state event), and
the pre-bind settle buffer is TTL-based — a burst of deferred
dispatches parked this tab's own raced settle first, where the old
size cap evicted exactly it.
- The command backstop / console proxy timeout inequality is enforced
by a test importing both named constants (both proxy_client
constructions, startup and the mTLS re-create); the compaction card
wears blue (magenta is reserved for the MCP surface); the redundant
TerminalUI.on_compaction override is gone (the inherited protocol
default is the policy site).
360 lines
16 KiB
Python
360 lines
16 KiB
Python
"""Unit tests for :class:`IdleNudgeWatcher`.
|
|
|
|
Drives a fake :class:`SessionManager` that mimics the real one's
|
|
``subscribe_to_state`` / ``get`` contract. The watcher itself
|
|
dispatches via ``turnstone.core.session_worker.send``; we patch that
|
|
module-level function to capture calls without spawning real threads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.idle_nudge_watcher import IdleNudgeWatcher, wake_workstream_if_pending
|
|
from turnstone.core.nudge_queue import NudgeQueue
|
|
from turnstone.core.workstream import WorkstreamState
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(self) -> None:
|
|
self._nudge_queue = NudgeQueue()
|
|
self.deliver_wake_nudge_from_queue_called = 0
|
|
|
|
def deliver_wake_nudge_from_queue(self) -> None:
|
|
self.deliver_wake_nudge_from_queue_called += 1
|
|
|
|
|
|
class _FakeWorkstream:
|
|
def __init__(self, ws_id: str = "ws-test") -> None:
|
|
self.id = ws_id
|
|
self.state = WorkstreamState.IDLE
|
|
self.session: _FakeSession | None = _FakeSession()
|
|
self._lock = threading.Lock()
|
|
self._worker_running = False
|
|
self._closed = False
|
|
self.worker_thread: Any = None
|
|
# Deferred /send entries — the wake gate yields while the order
|
|
# barrier holds; empty/None is the default every other test
|
|
# assumes.
|
|
self._pending_sends: list[Any] = []
|
|
self._pending_drain: Any = None
|
|
|
|
def send_barrier_active(self) -> bool:
|
|
# Mirrors Workstream.send_barrier_active — the gate calls the
|
|
# METHOD, so the stub must carry the same two-term pair.
|
|
drain = self._pending_drain
|
|
return bool(self._pending_sends) or (drain is not None and drain.is_alive())
|
|
|
|
|
|
class _FakeManager:
|
|
"""Mimics SessionManager's subscribe-to-state surface without a DB."""
|
|
|
|
def __init__(self) -> None:
|
|
self._workstreams: dict[str, _FakeWorkstream] = {}
|
|
self._subscribers: list[Any] = []
|
|
self._subscribers_lock = threading.Lock()
|
|
|
|
def add_ws(self, ws: _FakeWorkstream) -> None:
|
|
self._workstreams[ws.id] = ws
|
|
|
|
def get(self, ws_id: str) -> _FakeWorkstream | None:
|
|
return self._workstreams.get(ws_id)
|
|
|
|
def subscribe_to_state(self, callback: Any) -> None:
|
|
with self._subscribers_lock:
|
|
self._subscribers.append(callback)
|
|
|
|
def unsubscribe_from_state(self, callback: Any) -> None:
|
|
with self._subscribers_lock, contextlib.suppress(ValueError):
|
|
self._subscribers.remove(callback)
|
|
|
|
def fire_state(self, ws_id: str, state: WorkstreamState) -> None:
|
|
"""Mirror SessionManager.set_state's subscriber-fan-out behaviour."""
|
|
with self._subscribers_lock:
|
|
subs = list(self._subscribers)
|
|
for cb in subs:
|
|
# Match contextlib.suppress(Exception) in real SessionManager.
|
|
with contextlib.suppress(Exception):
|
|
cb(ws_id, state)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_mgr_and_ws() -> tuple[_FakeManager, _FakeWorkstream]:
|
|
mgr = _FakeManager()
|
|
ws = _FakeWorkstream()
|
|
mgr.add_ws(ws)
|
|
return mgr, ws
|
|
|
|
|
|
class TestIdleNudgeWatcher:
|
|
def test_idle_event_with_empty_queue_no_op(self, fake_mgr_and_ws):
|
|
mgr, ws = fake_mgr_and_ws
|
|
watcher = IdleNudgeWatcher(mgr)
|
|
watcher.start()
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_idle_event_with_pending_nudge_dispatches(self, fake_mgr_and_ws):
|
|
mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("idle_children", "your kids", "any")
|
|
watcher = IdleNudgeWatcher(mgr)
|
|
watcher.start()
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
|
assert mock_send.call_count == 1
|
|
kwargs = mock_send.call_args.kwargs
|
|
# `enqueue=lambda: None` — verify by calling and checking no-op.
|
|
assert kwargs["enqueue"]() is None
|
|
# `run` should call deliver_wake_nudge_from_queue when invoked.
|
|
kwargs["run"]()
|
|
assert ws.session.deliver_wake_nudge_from_queue_called == 1
|
|
assert kwargs["thread_name"].startswith("wake-nudge-")
|
|
|
|
def test_non_idle_state_ignored(self, fake_mgr_and_ws):
|
|
mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("foo", "bar", "any")
|
|
watcher = IdleNudgeWatcher(mgr)
|
|
watcher.start()
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
for state in (
|
|
WorkstreamState.RUNNING,
|
|
WorkstreamState.THINKING,
|
|
WorkstreamState.ATTENTION,
|
|
WorkstreamState.ERROR,
|
|
):
|
|
mgr.fire_state(ws.id, state)
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_unknown_ws_ignored(self, fake_mgr_and_ws):
|
|
mgr, _ws = fake_mgr_and_ws
|
|
watcher = IdleNudgeWatcher(mgr)
|
|
watcher.start()
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
mgr.fire_state("ghost", WorkstreamState.IDLE)
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_session_none_ignored(self, fake_mgr_and_ws):
|
|
mgr, ws = fake_mgr_and_ws
|
|
ws.session = None # workstream loaded but session not yet built
|
|
watcher = IdleNudgeWatcher(mgr)
|
|
watcher.start()
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_start_is_idempotent(self, fake_mgr_and_ws):
|
|
mgr, ws = fake_mgr_and_ws
|
|
watcher = IdleNudgeWatcher(mgr)
|
|
watcher.start()
|
|
watcher.start() # no-op
|
|
ws.session._nudge_queue.enqueue("foo", "bar", "any")
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
|
# Only one subscriber was registered despite the double-start.
|
|
assert mock_send.call_count == 1
|
|
|
|
def test_shutdown_unsubscribes(self, fake_mgr_and_ws):
|
|
mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("foo", "bar", "any")
|
|
watcher = IdleNudgeWatcher(mgr)
|
|
watcher.start()
|
|
watcher.shutdown()
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
mgr.fire_state(ws.id, WorkstreamState.IDLE)
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_shutdown_is_idempotent(self, fake_mgr_and_ws):
|
|
mgr, _ws = fake_mgr_and_ws
|
|
watcher = IdleNudgeWatcher(mgr)
|
|
watcher.start()
|
|
watcher.shutdown()
|
|
watcher.shutdown() # no error
|
|
|
|
|
|
class TestWakeWorkstreamIfPending:
|
|
"""Direct tests for the shared wake gate.
|
|
|
|
The IDLE-transition path (via the watcher) is covered above; these
|
|
pin the gates the watch dispatch closure relies on when it calls
|
|
the helper directly, with no state event involved.
|
|
"""
|
|
|
|
def test_wakes_idle_ws_with_pending_entry(self, fake_mgr_and_ws):
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
|
|
with patch("turnstone.core.session_worker.send", return_value=True) as mock_send:
|
|
assert wake_workstream_if_pending(ws) is True
|
|
assert mock_send.call_count == 1
|
|
kwargs = mock_send.call_args.kwargs
|
|
assert kwargs["enqueue"]() is None
|
|
kwargs["run"]()
|
|
assert ws.session.deliver_wake_nudge_from_queue_called == 1
|
|
assert kwargs["thread_name"].startswith("wake-nudge-")
|
|
|
|
def test_skips_session_none(self, fake_mgr_and_ws):
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session = None
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
assert wake_workstream_if_pending(ws) is False
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_yields_to_pending_deferred_sends(self, fake_mgr_and_ws):
|
|
"""Deferred /send entries hold the order barrier: a wake worker
|
|
claiming the slot would push messages already acknowledged
|
|
"queued" behind its whole turn, so the gate yields. Re-armed
|
|
structurally — every deferred turn's exit re-runs the gate, and
|
|
the drain's clean exit (trigger="drain-exit") covers a list that
|
|
emptied by pure retraction and never ran a turn."""
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
|
|
ws._pending_sends.append(object())
|
|
with patch("turnstone.core.session_worker.send", return_value=True) as mock_send:
|
|
assert wake_workstream_if_pending(ws, trigger="worker-exit") is False
|
|
assert mock_send.call_count == 0
|
|
# CLAIMED-entry window: list empty but the drain is alive (an
|
|
# acked entry was popped, its dispatch in flight). The one-term
|
|
# list check let a wake jump the acknowledged send here — the
|
|
# barrier's drain-alive term must hold the yield.
|
|
ws._pending_sends.clear()
|
|
ws._pending_drain = SimpleNamespace(is_alive=lambda: True)
|
|
with patch("turnstone.core.session_worker.send", return_value=True) as mock_send:
|
|
assert wake_workstream_if_pending(ws, trigger="worker-exit") is False
|
|
assert mock_send.call_count == 0
|
|
# Barrier fully cleared (the drain retired) — the same call
|
|
# dispatches.
|
|
ws._pending_drain = None
|
|
with patch("turnstone.core.session_worker.send", return_value=True) as mock_send:
|
|
assert wake_workstream_if_pending(ws, trigger="drain-exit") is True
|
|
assert mock_send.call_count == 1
|
|
|
|
def test_skips_closed_ws(self, fake_mgr_and_ws):
|
|
"""A workstream mid-``close()`` must not get a wake spawned on
|
|
its torn-down session, even while its ``state`` field still
|
|
reads IDLE (there is no CLOSED member — close uses the
|
|
``_closed`` tombstone)."""
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
|
|
ws._closed = True
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
assert wake_workstream_if_pending(ws) is False
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_skips_non_idle_states(self, fake_mgr_and_ws):
|
|
"""Busy states imply a live worker that drains at its own seams;
|
|
ERROR stays parked for the operator — neither gets a wake."""
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
for state in (
|
|
WorkstreamState.RUNNING,
|
|
WorkstreamState.THINKING,
|
|
WorkstreamState.ATTENTION,
|
|
WorkstreamState.ERROR,
|
|
):
|
|
ws.state = state
|
|
assert wake_workstream_if_pending(ws) is False
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_skips_tool_only_entries(self, fake_mgr_and_ws):
|
|
"""Tool-channel entries belong to the next tool-result seam — a
|
|
synthetic empty user turn can't drain them, so no wake."""
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("tool_error", "check memories", "tool")
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
assert wake_workstream_if_pending(ws) is False
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_refuses_non_nudgequeue_stub(self, fake_mgr_and_ws):
|
|
"""The gate refuses on TYPE, not just presence: a mock session's
|
|
auto-created ``_nudge_queue`` answers ``has_pending`` truthily
|
|
while its ``deliver_wake_nudge_from_queue`` consumes nothing —
|
|
with the worker-exit backstop re-running this gate after every
|
|
exit, one worker on such a session would respawn wake workers
|
|
forever (the storm that took down the full-suite CI run). Only
|
|
a real :class:`NudgeQueue` carries the drain semantics the wake
|
|
contract needs."""
|
|
from unittest.mock import MagicMock
|
|
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue = MagicMock() # truthy has_pending, no real drain
|
|
with patch("turnstone.core.session_worker.send") as mock_send:
|
|
assert wake_workstream_if_pending(ws) is False
|
|
assert mock_send.call_count == 0
|
|
|
|
def test_dispatched_path_logs_trigger(self, fake_mgr_and_ws, caplog):
|
|
"""A fresh spawn — ``send`` returns True without touching the
|
|
passed ``enqueue`` — emits ``nudge_wake.dispatched`` tagged with
|
|
the trigger label (structlog renders the event name + ``%s``
|
|
placeholders into ``msg``; substring-match like the sibling
|
|
nudge_queue tests)."""
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
|
|
with (
|
|
patch("turnstone.core.session_worker.send", return_value=True) as mock_send,
|
|
caplog.at_level(logging.INFO, logger="turnstone.core.idle_nudge_watcher"),
|
|
):
|
|
assert wake_workstream_if_pending(ws, trigger="idle-transition") is True
|
|
assert mock_send.call_count == 1
|
|
dispatched = [r for r in caplog.records if "nudge_wake.dispatched" in r.getMessage()]
|
|
assert len(dispatched) == 1
|
|
assert dispatched[0].levelno == logging.INFO
|
|
assert "trigger=" in dispatched[0].getMessage()
|
|
# The reuse-path drop line must not appear on a fresh spawn.
|
|
assert not any("nudge_wake.deferred_worker_busy" in r.getMessage() for r in caplog.records)
|
|
|
|
def test_deferred_path_logs_worker_busy(self, fake_mgr_and_ws, caplog):
|
|
"""The reuse path — ``send`` invokes the passed ``enqueue`` and
|
|
returns True — emits ``nudge_wake.deferred_worker_busy`` instead
|
|
of ``dispatched``. The entry stays owed to the owning worker's
|
|
exit backstop; the return value is still True."""
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
|
|
|
|
def _reuse_send(_ws: Any, *, enqueue: Any, run: Any, thread_name: Any) -> bool:
|
|
# Mimic a live worker owning the workstream: send routes the
|
|
# wake to the no-op enqueue rather than spawning a daemon.
|
|
enqueue()
|
|
return True
|
|
|
|
with (
|
|
patch("turnstone.core.session_worker.send", side_effect=_reuse_send) as mock_send,
|
|
caplog.at_level(logging.INFO, logger="turnstone.core.idle_nudge_watcher"),
|
|
):
|
|
assert wake_workstream_if_pending(ws, trigger="idle-transition") is True
|
|
assert mock_send.call_count == 1
|
|
deferred = [
|
|
r for r in caplog.records if "nudge_wake.deferred_worker_busy" in r.getMessage()
|
|
]
|
|
assert len(deferred) == 1
|
|
assert deferred[0].levelno == logging.INFO
|
|
assert "trigger=" in deferred[0].getMessage()
|
|
assert not any("nudge_wake.dispatched" in r.getMessage() for r in caplog.records)
|
|
|
|
def test_refused_path_logs_refusal(self, fake_mgr_and_ws, caplog):
|
|
"""``send`` refusing outright — its authoritative under-lock
|
|
``_closed`` re-check caught a teardown the gate's lockless peek
|
|
missed — emits ``nudge_wake.refused``: a dropped wake must stay
|
|
traceable to its trigger, not vanish silently."""
|
|
_mgr, ws = fake_mgr_and_ws
|
|
ws.session._nudge_queue.enqueue("watch_triggered", "output", "any")
|
|
with (
|
|
patch("turnstone.core.session_worker.send", return_value=False) as mock_send,
|
|
caplog.at_level(logging.INFO, logger="turnstone.core.idle_nudge_watcher"),
|
|
):
|
|
assert wake_workstream_if_pending(ws, trigger="watch-fire") is False
|
|
assert mock_send.call_count == 1
|
|
refused = [r for r in caplog.records if "nudge_wake.refused" in r.getMessage()]
|
|
assert len(refused) == 1
|
|
assert refused[0].levelno == logging.INFO
|
|
assert "trigger=" in refused[0].getMessage()
|
|
assert not any("nudge_wake.dispatched" in r.getMessage() for r in caplog.records)
|
|
assert not any("nudge_wake.deferred_worker_busy" in r.getMessage() for r in caplog.records)
|