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

734 lines
33 KiB
Python

"""Boot the REAL interactive Turnstone server for the SSE recovery e2e
harness: real ``SessionManager`` + real ``ChatSession`` engine driven
through a scripted chat-completions client at the SDK boundary, executing
REAL bash tools, exposed over a real uvicorn socket.
The recipe (verified end-to-end) has four load-bearing pieces:
1. **Provider injection seam.** ``create_app`` takes a PRE-BUILT
``SessionManager``, so the harness owns the ``session_factory``: it
passes ``client=fake_client`` and OMITS the registry, so
``ChatSession`` falls back to ``create_provider("openai-compatible")``
== ``OpenAIChatCompletionsProvider`` — exactly what
``tests._session_helpers.scripted_chat_client`` targets. No production
monkeypatch of the engine.
2. **Auto-title suppression.** The first user message spawns a background
``_generate_title`` LLM call that would consume the first scripted
response (the tool call) and desync a positional script. Setting
``session._title_generated = True`` before the first send disables it.
3. **Completion barrier.** ``/send`` returns immediately after spawning
``ws.worker_thread``; joining that thread is the true "turn complete,
every SSE event enqueued" barrier (``stream_end`` is per-LLM-call, not
per-turn, so it is NOT a completion marker).
4. **Thread hygiene.** ``create_app``'s lifespan starts daemon fan-out
threads (``_global_fanout_thread`` blocking on ``global_queue.get()``,
``_aggregate_emitter_thread`` on a 10s loop). The harness used to
swap them for no-ops (they had no shutdown and tripped conftest's
leaked-thread guard), which kept the global lane dead here; #885 gave
the lifespan a real shutdown (stop Event + a queue sentinel for the
fanout, joined in the lifespan exit that ``stop()``'s
``should_exit``/join drives), so the harness now runs them REAL — the
``roster-restart`` scenario depends on a live global lane — and
teardown stays clean with no ``allow_thread_leak``.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import queue as _q
import socket
import threading
import time
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import httpx
import uvicorn
from tests._session_helpers import scripted_chat_client
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
from turnstone.core.session import ChatSession
from turnstone.core.session_manager import SessionManager
from turnstone.core.session_ui_base import SessionUIBase
from turnstone.core.storage import get_storage
from turnstone.core.workstream import WorkstreamKind
from turnstone.prompts import ClientType
from turnstone.server import WebUI, create_app
if TYPE_CHECKING:
from collections.abc import MutableMapping
from turnstone.core.workstream import Workstream
_JWT_SECRET = "sse-recovery-e2e-jwt-secret-minimum-32-chars!"
# Small server send buffer so a stalled consumer's in-flight backlog before
# the listener-queue poison stays bounded (paired with the client's small
# SO_RCVBUF in _sse_recovery_helpers). Harmless for prompt readers.
_DEFAULT_SNDBUF = 8192
def _fake_client(scripts: tuple[Any, ...]) -> Any:
"""An SDK-shaped fake whose ``chat.completions.create`` follows a
positional script (each a :func:`fake_chat_stream` kwargs dict)."""
create_fn = scripted_chat_client(*scripts)
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create_fn)))
client.calls = create_fn.calls
return client
class RecoveryServer:
"""A booted interactive node the recovery scenarios drive."""
def __init__(
self,
*,
sndbuf: int = _DEFAULT_SNDBUF,
listener_cap: int | None = None,
extra_routes: list[Any] | None = None,
port: int = 0,
sock: socket.socket | None = None,
) -> None:
self._global_queue: _q.Queue[dict[str, Any]] = _q.Queue(maxsize=100000)
self._global_listeners: list[_q.Queue[dict[str, Any]]] = []
self._global_listeners_lock = threading.Lock()
# Per-ws scripted client, resolved at factory-call time.
self._pending_client: Any = _fake_client((dict(content="ok", finish_reason="stop"),))
self._clients: dict[str, Any] = {}
WebUI._global_queue = self._global_queue
def session_factory(
ui: Any,
model_alias: str | None = None,
ws_id: str | None = None,
*,
skill: Any = None,
client_type: str = "",
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
parent_ws_id: str | None = None,
project_id: str = "",
**_extra: Any,
) -> ChatSession:
client = self._pending_client
if ws_id is not None:
self._clients[ws_id] = client
return ChatSession(
client=client,
model="test-model",
ui=ui,
instructions=None,
temperature=None,
max_tokens=1024,
tool_timeout=30,
ws_id=ws_id,
user_id="recovery-user",
client_type=ClientType.WEB,
kind=kind,
# Don't truncate large tool outputs: the harness tests
# recovery, not the tool-result truncation budget, and a
# truncated /history would diverge from the full live event
# and defeat the convergence assertions.
tool_truncation=10_000_000,
)
self._adapter = InteractiveAdapter(
global_queue=self._global_queue,
ui_factory=lambda ws: WebUI(
ws_id=ws.id, user_id=ws.user_id, kind=ws.kind, parent_ws_id=ws.parent_ws_id
),
session_factory=session_factory,
)
self._manager = SessionManager(
self._adapter, storage=get_storage(), max_active=32, node_id="recovery-node"
)
self._adapter.attach(self._manager)
WebUI._workstream_mgr = self._manager
# delay_load knob state (see the method): wraps the storage
# singleton's load_messages; restored in stop().
self._load_delay_ms = 0
self._load_calls = 0
# load_messages runs on asyncio.to_thread WORKERS, and delay_load
# exists precisely to overlap two of them — unlike the
# single-writer HTTP counters, this one has genuine concurrent
# writers, so the increment takes a lock (a lost update would
# false-FAIL G7's load_delta === 2, or mask a third load).
self._load_calls_lock = threading.Lock()
_storage_obj = get_storage()
self._orig_load_messages = _storage_obj.load_messages
def _delayed_load(*a: Any, **k: Any) -> Any:
with self._load_calls_lock:
self._load_calls += 1
result = self._orig_load_messages(*a, **k)
# Sleep AFTER the load: the held flight must hold the data it
# actually read (its transaction point), so a flight parked
# across a rewind genuinely carries PRE-rewind rows — a
# pre-load sleep would read post-rewind storage and mask a
# wrongly-joined flight as fresh truth.
d = self._load_delay_ms
if d > 0:
time.sleep(d / 1000.0)
return result
_storage_obj.load_messages = _delayed_load # type: ignore[method-assign]
self._patched_storage = _storage_obj
# Optional small listener-queue cap. The cap is a default arg on the
# registration methods with no config/env override, so lower it by
# patching their ``__defaults__`` (restored on stop). fix-3's
# de-amplification makes a real 500-cap overflow need a pathological
# storm; a small cap exercises the identical _ListenerOverflow ->
# stream_overflow -> reconnect-replay path within a bounded storm.
self._orig_defaults: list[tuple[Any, tuple[Any, ...] | None]] = []
if listener_cap is not None:
for meth in (
SessionUIBase._register_listener,
SessionUIBase.register_listener_with_in_progress_snapshot,
SessionUIBase.register_listener_with_replay,
):
self._orig_defaults.append((meth, meth.__defaults__))
meth.__defaults__ = (listener_cap,)
self._app = create_app(
workstreams=self._manager,
global_queue=self._global_queue,
global_listeners=self._global_listeners,
global_listeners_lock=self._global_listeners_lock,
skip_permissions=True,
jwt_secret=_JWT_SECRET,
node_id="recovery-node",
# /history + tenant checks read app.state.auth_storage.
auth_storage=get_storage(),
)
# Same-origin extras (Tier 2 serves its recovery page here so the real
# Pane's cookie auth + EventSource work without cross-origin plumbing).
if extra_routes:
self._app.router.routes.extend(extra_routes)
# Pre-bind a listening socket with a small SO_SNDBUF (accepted conns
# inherit it), then hand it to uvicorn. ``sock`` injection: the
# gap-free restart scenarios (roster-restart-native) bind a
# placeholder BEFORE stopping the prior node and hand it in here —
# a failed EventSource reconnect attempt is TERMINAL per WHATWG
# (fail-the-connection → CLOSED, no further retries), so the
# native-retry leg must never observe a refused-window; the
# placeholder's listen backlog completes the TCP handshake during
# the boot and uvicorn drains it once serving.
self._sock = sock if sock is not None else make_listen_socket(port, sndbuf=sndbuf)
self._port = int(self._sock.getsockname()[1])
# -- fault injection (public knobs below) ----------------------------
# In-process arming: the Tier-2 runner holds this RecoveryServer and
# arms a knob, THEN drives the browser request that consumes it.
# Single-writer by construction — the runner never arms a knob while
# the loop thread is mid-consume — and CPython makes each int read /
# write atomic, so these need no lock even though the uvicorn loop
# thread increments/decrements them while the runner thread reads.
self.history_requests = 0
# /history responses the PRODUCTION route answered 200 (not the
# fault layer's injected 500s). See _fault_app for why arrival
# counting cannot substitute.
self.history_ok = 0
self.rewind_requests = 0
# Per-ws SSE connection opens (``GET …/events`` — the EventSource the
# pane's connectSSE builds). A TRANSPORT-FREE heal (the #890 idle-edge
# staleness backstop, a quiesced REST refetch) must leave this FLAT; a
# reload-based backstop would bump it once per reconnect (the round-5
# storm). Same lock-free single-writer int discipline as above.
self.events_requests = 0
# Global-lane SSE connection opens (``GET …/events/global`` — the
# roster stream app.js's connectGlobalSSE builds). The
# roster-restart scenario (#881) asserts the post-restart manual
# reconnect actually reached the reborn node's real endpoint.
self.global_events_requests = 0
self._history_fail_remaining = 0
self._history_tokenless_remaining = 0
self._history_delay_ms = 0
# A thin pure-ASGI fault layer wrapping the REAL app (the production
# app itself is untouched): count + optionally delay/fail
# ``GET …/history``, count ``POST …/rewind``, count each per-ws SSE
# connection open (``GET …/events``), forward everything else (SSE
# bodies, /send, lifespan, static) verbatim.
production_app = self._app
async def _fault_app(scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope.get("type") == "http":
path = scope.get("path", "")
method = scope.get("method", "")
if path.endswith("/history") and method == "GET":
# ARRIVAL, never move. Scenarios that hold a request open
# use this bump as the IN-FLIGHT edge (E6/E7/G1/G7 say so
# at their poll sites); counting on forward instead would
# delay it past the hold and silently stop those scenarios
# testing anything.
self.history_requests += 1
if self._history_delay_ms > 0:
await asyncio.sleep(self._history_delay_ms / 1000.0)
if self._history_fail_remaining > 0:
self._history_fail_remaining -= 1
await send(
{
"type": "http.response.start",
"status": 500,
"headers": [(b"content-type", b"application/json")],
}
)
await send({"type": "http.response.body", "body": b'{"error": "injected"}'})
return
if self._history_tokenless_remaining > 0:
# Old/malformed server simulation for the strong
# handoff-repair latch. A 200 without handoff_token is
# not proof and must never authorize EventSource.
self._history_tokenless_remaining -= 1
self.history_ok += 1
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"application/json")],
}
)
await send(
{
"type": "http.response.body",
"body": (b'{"ws_id":"compat","messages":[],"cursor":null}'),
}
)
return
# Successful-RESPONSE counter, distinct from the arrival
# bump above. A scenario asserting that a render was
# DECLINED needs to know a good payload actually existed —
# otherwise "the client refused to render" and "there was
# nothing to render" produce identical observables (no
# wipe, latch held). Arrival cannot prove that, and
# neither can an injected-fail budget: a PRODUCTION-side
# 500/404 would slip through both. Reading the real
# status off the response start is the only honest signal.
async def _counting_send(message: MutableMapping[str, Any]) -> None:
if (
message.get("type") == "http.response.start"
and message.get("status") == 200
):
self.history_ok += 1
await send(message)
await production_app(scope, receive, _counting_send)
return
elif path.endswith("/rewind") and method == "POST":
self.rewind_requests += 1
elif path.endswith("/events") and method == "GET":
# Per-ws SSE connection open — count it (readable on
# RecoveryServer) and forward the long-lived stream
# verbatim below. Uniquely the per-ws stream: the global
# lane is ``…/events/global`` (ends ``/global``), and the
# route the pane's EventSource hits is
# ``…/workstreams/{ws_id}/events`` (session_routes).
self.events_requests += 1
elif path.endswith("/events/global") and method == "GET":
self.global_events_requests += 1
await production_app(scope, receive, send)
# ``timeout_graceful_shutdown``: an SSE stream that is still open
# at ``stop()`` would otherwise park uvicorn's graceful drain
# indefinitely (the 20s thread-join just expires and the browser
# stays attached to the zombie server — the roster-restart-native
# scenario is the one caller that stops a node mid-stream). A
# bounded drain force-closes the stream after 2s and the lifespan
# shutdown (#885's daemon-thread teardown) still runs after it.
self._server = uvicorn.Server(
uvicorn.Config(
_fault_app,
log_level="warning",
lifespan="on",
timeout_graceful_shutdown=2,
)
)
self._thread = threading.Thread(
target=self._serve, name=f"uvicorn-recovery-{self._port}", daemon=True
)
self._thread.start()
if not _tcp_ready(self._port, 10.0):
self.stop()
raise AssertionError("recovery server did not accept TCP")
self._token = create_jwt(
user_id="recovery-user",
scopes=frozenset({"read", "write", "approve", "service"}),
source="recovery",
secret=_JWT_SECRET,
audience=JWT_AUD_SERVER,
)
self._http = httpx.Client(base_url=self.base_url, timeout=httpx.Timeout(30.0))
def _serve(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._server.serve(sockets=[self._sock]))
finally:
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
with contextlib.suppress(Exception):
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
# -- properties ----------------------------------------------------------
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self._port}"
@property
def token(self) -> str:
return self._token
@property
def manager(self) -> SessionManager:
return self._manager
# -- workstream lifecycle ------------------------------------------------
def create_workstream(self, *scripts: Any, name: str = "recovery-ws") -> str:
"""Create a ws whose scripted LLM follows ``scripts`` (positional
:func:`fake_chat_stream` kwargs). Auto-approves tools and suppresses
the auto-title call so the positional script stays in sync."""
self._pending_client = _fake_client(scripts)
ws = self._manager.create(user_id="recovery-user", name=name)
self._prime_ws(ws)
return ws.id
def open_workstream(self, ws_id: str, *scripts: Any) -> None:
"""Rehydrate a persisted ws on THIS node (the restart path). Fresh
UI → empty ring + storage-seeded ``_event_id``."""
if scripts:
self._pending_client = _fake_client(scripts)
ws = self._manager.open(ws_id)
if ws is None:
raise AssertionError(f"open_workstream: ws {ws_id} not resurrectable")
self._prime_ws(ws)
def _prime_ws(self, ws: Workstream) -> None:
if isinstance(ws.ui, SessionUIBase):
ws.ui.auto_approve = True # blanket tool auto-approval
if ws.session is not None:
ws.session._title_generated = True # suppress the auto-title LLM call
def send(self, ws_id: str, message: str = "go") -> None:
"""POST /send — spawns the worker thread and returns immediately."""
r = self._http.post(
f"/v1/api/workstreams/{ws_id}/send",
headers={"Authorization": f"Bearer {self._token}"},
json={"message": message},
)
r.raise_for_status()
def wait_turn(self, ws_id: str, *, timeout: float = 45.0) -> None:
"""Block until the turn's worker thread finishes (the true
turn-complete barrier) and the ws is idle."""
deadline = time.monotonic() + timeout
worker: threading.Thread | None = None
while time.monotonic() < deadline:
ws = self._manager.get(ws_id)
worker = ws.worker_thread if ws is not None else None
if worker is not None:
break
time.sleep(0.02)
if worker is not None:
worker.join(timeout=max(0.5, deadline - time.monotonic()))
if worker.is_alive():
raise AssertionError(f"turn worker for {ws_id} did not finish in {timeout}s")
def get_ws(self, ws_id: str) -> Workstream | None:
return self._manager.get(ws_id)
def ws_state(self, ws_id: str) -> str:
ws = self._manager.get(ws_id)
return ws.state.value if ws is not None else ""
def ring_span(self, ws_id: str) -> tuple[int | None, int]:
"""(earliest retained ring event_id or None, latest counter) — lets a
scenario wait for the ring to evict a specific cursor."""
ws = self._manager.get(ws_id)
ui = ws.ui if ws is not None else None
if not isinstance(ui, SessionUIBase):
return None, 0
buf = ui._event_buffer
earliest = buf[0][0] if buf else None
return earliest, ui._event_id
def listener_poisoned(self, ws_id: str) -> bool:
"""True once any live SSE listener on the ws has poisoned (overflow)."""
ws = self._manager.get(ws_id)
ui = ws.ui if ws is not None else None
if not isinstance(ui, SessionUIBase):
return False
return any(getattr(q, "poisoned", False) for q in list(ui._listeners))
def max_event_id(self, ws_id: str) -> int | None:
"""The storage high-water ``MAX(conversations.event_id)`` — what a
restarted node's fresh UI seeds ``_event_id`` from."""
result: int | None = get_storage().get_max_event_id(ws_id)
return result
def emit_idle_edge(self, ws_id: str) -> int:
"""Publish one real per-workstream ``state_change: idle`` event.
Recovery scenarios use this test-server pulse when they need an
organic-settle-equivalent edge without admitting another user row.
A normal ``/send`` is not a neutral trigger: it publishes a live
``user_turn``, starts model work, and changes the transcript/counts
these scenarios use to isolate the stale-history backstop.
"""
ws = self._manager.get(ws_id)
ui = ws.ui if ws is not None else None
if not isinstance(ui, SessionUIBase):
raise AssertionError(f"emit_idle_edge: ws {ws_id} has no session UI")
before = ui._event_id
ui.on_state_change("idle")
if ui._event_id != before + 1:
raise AssertionError(
f"emit_idle_edge: expected one event after {before}, got {ui._event_id}"
)
return ui._event_id
def emit_history_resync(self, ws_id: str, reason: str = "recovery_probe") -> int:
"""Publish the real strong repair frame through the ordered UI lane."""
ws = self._manager.get(ws_id)
ui = ws.ui if ws is not None else None
if not isinstance(ui, SessionUIBase):
raise AssertionError(f"emit_history_resync: ws {ws_id} has no session UI")
before = ui._event_id
ui.on_history_resync(reason)
if ui._event_id != before + 1:
raise AssertionError(
f"emit_history_resync: expected one event after {before}, got {ui._event_id}"
)
return ui._event_id
def emit_tool_pending(self, ws_id: str, call_id: str) -> int:
"""Publish a live ``tool_pending`` phase without persisting a turn.
This drives the coordinator's event-owned ``liveToolCalls`` gate in
isolation. ``on_agent_step`` is the production hook that emits this
exact envelope; the synthetic item is intentionally not added to
history, so a later authoritative repaint must remove its DOM shell.
"""
ws = self._manager.get(ws_id)
ui = ws.ui if ws is not None else None
if not isinstance(ui, SessionUIBase):
raise AssertionError(f"emit_tool_pending: ws {ws_id} has no session UI")
before = ui._event_id
ui.on_agent_step(
"",
{
"call_id": call_id,
"func_name": "recovery_probe",
"approval_label": "recovery probe",
"header": "recovery render-gate probe",
"needs_approval": False,
},
)
if ui._event_id != before + 1:
raise AssertionError(
f"emit_tool_pending: expected one event after {before}, got {ui._event_id}"
)
return ui._event_id
def emit_tool_result(self, ws_id: str, call_id: str) -> int:
"""Resolve a tool pulse emitted by :meth:`emit_tool_pending`."""
ws = self._manager.get(ws_id)
ui = ws.ui if ws is not None else None
if not isinstance(ui, SessionUIBase):
raise AssertionError(f"emit_tool_result: ws {ws_id} has no session UI")
before = ui._event_id
ui.on_tool_result(call_id, "recovery_probe", "probe complete")
if ui._event_id != before + 1:
raise AssertionError(
f"emit_tool_result: expected one event after {before}, got {ui._event_id}"
)
return ui._event_id
def fetch_history(self, ws_id: str) -> dict[str, Any]:
r = self._http.get(
f"/v1/api/workstreams/{ws_id}/history",
headers={"Authorization": f"Bearer {self._token}"},
)
r.raise_for_status()
result: dict[str, Any] = r.json()
return result
# -- fault-injection knobs -----------------------------------------------
# Armed in-process by the Tier-2 runner (single writer at a time — see
# __init__). A plain int is deliberate: CPython makes the loop thread's
# increment/decrement and the runner thread's read each atomic, and the
# arm-then-consume ordering means they never race.
def delay_load(self, ms: int) -> None:
"""Hold ``storage.load_messages`` itself open for ``ms`` (0 = off).
``delay_history`` sleeps in the FAULT LAYER — before the route —
so two delayed requests never overlap inside the #884 flight
machinery (the first flight completes and pops before the second
arrives at the route). This knob sleeps INSIDE the shared
reconstruction's ``load_messages`` (sync, called via
``asyncio.to_thread`` — the sleep parks only that worker), which
is the same layer the unit tests gate, so held flights genuinely
overlap and join/miss behavior is observable end to end via
``load_calls``.
"""
self._load_delay_ms = ms
@property
def load_calls(self) -> int:
"""``load_messages`` entries (pre-sleep) — the flight-layer twin
of ``history_requests`` (which counts HTTP arrivals): a JOINED
request never enters ``load_messages``, so join=1 / miss=2."""
return self._load_calls
def fail_history(self, count: int) -> None:
"""Make the next ``count`` ``GET …/history`` responses a 500 — the
failed refetch the #890 guard-before-wipe must survive."""
self._history_fail_remaining = count
def tokenless_history(self, count: int) -> None:
"""Make the next history responses 200 without a handoff proof."""
self._history_tokenless_remaining = count
def delay_history(self, ms: int) -> None:
"""Hold each ``GET …/history`` ``ms`` ms before forwarding (0
clears). Opens the clear_ui-refetch quiesce window that the row
affordance gate (``busy || _historyStale``) must close."""
self._history_delay_ms = ms
@property
def history_fail_remaining(self) -> int:
"""Unconsumed forced-failure budget — 0 proves the armed failure
actually fired (assert backend state, never scripted absence)."""
return self._history_fail_remaining
# -- teardown ------------------------------------------------------------
def stop(self, *, hard: bool = False) -> None:
"""Stop the node.
``hard=True`` skips the per-workstream ``manager.close`` sweep — a
graceful close routes through ``cleanup_session_ui`` →
``session.cancel()``, whose bash cancel path PERSISTS a
synthesized "Cancelled by user" result while the old node is
still alive, which masks crash states. A hard stop leaves any
in-flight tool call genuinely unresulted in storage, modelling a
SIGKILL/OOM death (the coord-orphan-rewind scenario's premise).
The 2s graceful-shutdown timeout (uvicorn config) force-closes
open SSE streams, and the lifespan teardown still runs, so the
#885 daemon threads are joined on both paths.
"""
if not hard:
with contextlib.suppress(Exception):
for ws in list(self._manager.list_all()):
with contextlib.suppress(Exception):
self._manager.close(ws.id)
# hard=True relies on ``timeout_graceful_shutdown=2`` (set in the
# uvicorn config above) to force-close the pane's EventSource:
# should_exit alone still runs the ASGI lifespan teardown, so the
# #885 daemon threads and the sse_executor are joined either way
# (``force_exit`` would SKIP the lifespan and leak them — the
# fanout thread blocks on queue.get() forever). NOTE: the killed
# workstream's in-flight tool keeps executing on this process's
# session thread and persists its result at natural completion —
# hard-kill scenarios must use a paced tool that outlives their
# observation window.
self._server.should_exit = True
self._thread.join(timeout=20)
with contextlib.suppress(Exception):
self._http.close()
with contextlib.suppress(OSError):
self._sock.close()
# Restore any patched cap defaults.
for meth, defaults in self._orig_defaults:
meth.__defaults__ = defaults
# Restore the storage singleton's load_messages (delay_load knob).
with contextlib.suppress(Exception):
self._patched_storage.load_messages = self._orig_load_messages # type: ignore[method-assign]
def make_listen_socket(port: int, *, sndbuf: int = _DEFAULT_SNDBUF) -> socket.socket:
"""Bound + listening socket the way :class:`RecoveryServer` binds its own.
``SO_REUSEPORT`` on every listener (same process, same uid) is what
lets a restart scenario bind the successor's socket while the prior
node still holds the port — the seam behind the gap-free handoff
documented at the ``sock`` parameter. ``SO_SNDBUF`` matches the
server's small send buffer so accepted connections inherit identical
backpressure behavior regardless of which side bound the socket.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, sndbuf)
s.bind(("127.0.0.1", port)) # port=0 -> ephemeral; fixed -> restart reuse
s.listen(128)
return s
def _tcp_ready(port: int, timeout: float) -> bool:
end = time.monotonic() + timeout
while time.monotonic() < end:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
return True
except OSError:
time.sleep(0.05)
return False
def bash_toolcall_script(
call_id: str, command: str, *, finish_reason: str = "tool_calls"
) -> dict[str, Any]:
"""A scripted assistant turn issuing ONE bash tool call."""
return dict(
tool_calls=[{"id": call_id, "name": "bash", "arguments": json.dumps({"command": command})}],
finish_reason=finish_reason,
)
def parallel_bash_script(commands: dict[str, str]) -> dict[str, Any]:
"""A scripted assistant turn issuing SEVERAL bash tool calls at once
(the parallel-pool storm), ``{call_id: command}``.
Each command is prefixed with a no-op ``: <call_id>;`` so the tool
ARGUMENTS are distinct per call while the OUTPUT is unchanged (``:``
ignores its args and prints nothing). Identical-argument parallel
calls otherwise trip the session's repeat-tool-call guard, which
appends a warning to the PERSISTED result only (not the live event) —
an orthogonal divergence that would mask the recovery behavior the
convergence assertions test.
"""
return dict(
tool_calls=[
{
"id": cid,
"name": "bash",
"arguments": json.dumps({"command": f": {cid}; {cmd}"}),
}
for cid, cmd in commands.items()
],
finish_reason="tool_calls",
)
def final_text_script(content: str = "done") -> dict[str, Any]:
"""The scripted assistant turn that ends the agent loop (no tools)."""
return dict(content=content, finish_reason="stop")