Files
turnstone/turnstone/core/workstream.py
T
Patrick Buckley d280db514e fix(compaction): review round 9 — drain-exit ownership, missed-edge settle, pre-turn hook guard
Three point-guards from the ceiling round (no primitive took a hit;
correctness yield halved at identical review sensitivity):

- The drain's clean-exit wake moved OUT of the function-level try: it
  runs after the drain has already retired its slot, so a raise out of
  the wake (the dispatcher re-raises Thread.start failures) could reach
  the last-resort handler and clear a slot this thread no longer owned —
  nulling a successor drain's live registration and letting two drains
  service one list. The wake now runs post-try under its own guard
  (mirroring _retry_pending_wake), only on the clean-exit path, and the
  last-resort slot-clear is identity-guarded like every sibling exit
  seam. The except arm needed a function-local threading import: the
  module-top import is TYPE_CHECKING-only, so the guard would have
  NameErrored inside the handler with strict mypy fully green.
- The shared settle helper promotes a non-deferred chip that binds onto
  an already-idle pane: its only sweep fired mid-POST (unbound then) and
  no message_dispatched ever comes for non-deferred sends, so the chip
  stayed a permanently retractable "queued" bubble for a delivered
  message. Keyed on post-bind chip state (also catching a raced folded
  settle bind just reconciled) and skipping dismiss-in-flight chips —
  the sweep's own aria-busy discipline. Pinned behaviorally: the helper
  now executes under node (a 4-row missed-edge matrix), possible since
  the consumer-less window bridge is gone.
- _claim_generation's on_generation_claimed emission is call-guarded:
  it sits on send()'s pre-turn path, before the user turn is appended
  and before the fatal handler's coverage, so a raising override
  degrades to a lost latch-break instead of silently dropping every
  user message on that session.

Cleanups: /command's transport catch and status-less non-2xx bodies are
loud now (threading {ok, status} through the parse — deliberately no
throw-on-!ok pre-gate, since the busy and error arms ride 409/503);
PENDING_SENDS_MAX lives in workstream.py and ChatSession._QUEUE_MAX
aliases it (one backpressure bound, structurally incapable of
diverging); the send handler's not-ok arm uses _queue_full_response();
the dead window.createQueueController bridge is deleted and the file
header's consumer map corrected.
2026-07-17 11:28:44 -07:00

292 lines
14 KiB
Python

"""Workstream types — the kind enum, state enum, and per-workstream dataclass.
A workstream is an independent conversation with its own ChatSession and UI
adapter. Lifecycle (create/open/close/set_state/eviction/SSE fan-out) lives
on :class:`turnstone.core.session_manager.SessionManager`; this module only
defines the data types both interactive and coordinator kinds share.
"""
from __future__ import annotations
import enum
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from collections.abc import Callable
from turnstone.core.session import ChatSession, SessionUI
# What KIND of work a worker slot holds. A Literal (not a free-form str)
# so a typo'd comparison or a new dispatch caller passing "commands" is a
# type error instead of a silently never-firing command-window guard —
# the /send defer keys on exactly these values.
WorkerKind = Literal["", "turn", "command"]
# ---------------------------------------------------------------------------
# Kind enum — single source of truth for the workstream dispatch classifier
# ---------------------------------------------------------------------------
class WorkstreamKind(enum.StrEnum):
"""Classifier for which manager hosts a workstream.
StrEnum so members are drop-in ``str`` replacements for the DB column,
JSON payloads, and existing ``==`` comparisons against raw strings.
Narrow internal annotations to this type; wide boundaries (HTTP body,
DB row) stay ``str`` and parse via ``WorkstreamKind(raw)`` / ``from_raw``
at the edge.
"""
INTERACTIVE = "interactive" # hosted by the node's interactive SessionManager
COORDINATOR = "coordinator" # hosted by the console's coordinator SessionManager
@classmethod
def from_raw(
cls,
value: WorkstreamKind | str | None,
*,
default: WorkstreamKind | None = None,
) -> WorkstreamKind:
"""Parse an externally-supplied kind value with a fallback for missing data.
Handles the three shapes that arrive from storage rows and wire
payloads — already-an-enum, non-empty string, None/empty — so the
``WorkstreamKind(x or WorkstreamKind.INTERACTIVE.value)`` dance
(``or`` short-circuits on a truthy enum member and skips the
default, forcing every caller to reach for ``.value``) collapses
into a single predictable call.
``default`` defaults to ``INTERACTIVE`` when omitted. Raises
``ValueError`` for a non-empty string that doesn't match any
known kind — callers that want to coerce unknowns to the default
should catch and fall back explicitly.
"""
effective_default = default if default is not None else cls.INTERACTIVE
if value is None or value == "":
return effective_default
return cls(value)
# ---------------------------------------------------------------------------
# State enum
# ---------------------------------------------------------------------------
class WorkstreamState(enum.Enum):
IDLE = "idle" # waiting for user input
THINKING = "thinking" # LLM is streaming
RUNNING = "running" # tools executing
ATTENTION = "attention" # blocked on approval / plan review
ERROR = "error" # last operation failed
# States the orphan reaper (``SessionManager.close_idle`` pass 2 +
# ``StorageBackend.bulk_close_stale_orphans``) is allowed to flip to
# ``closed`` for rows past the staleness cutoff. Excludes ``ERROR``
# deliberately — error rows are user-investigatable and shouldn't be
# auto-reaped — and excludes ``CLOSED`` (terminal). Centralized here
# so the storage backends and FakeStorage all agree; if a new transient
# state is added to ``WorkstreamState``, deciding whether it joins
# this set is part of the change rather than an after-the-fact
# audit across three files.
BULK_CLOSE_STATE_VALUES: frozenset[str] = frozenset(
{
WorkstreamState.IDLE.value,
WorkstreamState.THINKING.value,
WorkstreamState.RUNNING.value,
WorkstreamState.ATTENTION.value,
}
)
# ---------------------------------------------------------------------------
# Deferred sends
# ---------------------------------------------------------------------------
# Per-workstream backpressure bound shared by BOTH message-acceptance
# surfaces: the deferred-send list below refuses appends at this size
# (the 11th answers the retryable ``queue_full``), and
# ``ChatSession._QUEUE_MAX`` aliases it for the mid-turn interjection
# queue — one constant, so busy-window and command-window sends can
# never silently diverge on saturation behavior. Each accepted deferred
# entry pins its full message text plus materialized attachment bytes
# for the length of a command window and then costs one unattended
# turn, so acceptance must be bounded.
PENDING_SENDS_MAX = 10
@dataclass
class _PendingSend:
"""One send deferred while the /send order barrier holds.
A /send that lands while a slash-command worker holds the slot
(``ws.worker_kind == "command"`` — a manual /compact can hold it for
minutes) or while earlier deferred entries are still pending is
answered ``{"status": "queued", "deferred": true, "msg_id": ...}``
immediately and registered here;
:func:`turnstone.core.session_routes._drain_pending_sends` dispatches
it full-fidelity when the slot frees. This replaces the parked-POST
design, which encoded "client disconnected" as "message retracted" —
true only for the web composers' ✕-abort; every bounded caller (the
coordinator client and the console proxy at timeout=30, SDKs, curl)
times out instead, and its message was deliberately dropped for the
whole window.
``attempt`` is the prebuilt one-session-capture dispatch closure
(:func:`turnstone.core.session_routes._make_dispatch_attempt` with
``defer_fidelity=True``), so the drain stays endpoint-agnostic —
everything kind-specific (attachments, spawn metrics, UI hooks) was
captured at defer time. ``retracted`` is flipped under ``ws._lock``
by the DELETE dequeue fall-through; the drain never dispatches a
retracted entry.
Durability contract (documented in the API reference): node-local
and in-memory, the interjection queue's lifetime — entries die with
the workstream or the process, so "queued" is at-most-once intake,
not durable acceptance.
Invariant both the route's order barrier and the drain depend on
(and the second term of :meth:`Workstream.send_barrier_active`
exists to honor): **drain not alive ⇒ nothing claimed.** The drain
pops an entry only while it lives and re-inserts it at head on ANY
non-dispatch outcome (rejection or crash), so a dead/absent drain
means every accepted entry is on the list — the barrier term pair
(list non-empty OR drain alive) therefore covers the claimed-entry
window with no third state.
(No ``priority`` field: dispatch is strictly FIFO — arrival order is
the contract — and the queued response/event use the route's parsed
local. A deferred entry's ``!!!`` prefix still reaches the model:
the full text dispatches as an ordinary send.)
"""
msg_id: str
attempt: Callable[[ChatSession], tuple[bool, dict[str, Any]]]
retracted: bool = False
# ---------------------------------------------------------------------------
# Workstream dataclass
# ---------------------------------------------------------------------------
@dataclass
class Workstream:
id: str = field(default_factory=lambda: uuid.uuid4().hex)
name: str = ""
state: WorkstreamState = WorkstreamState.IDLE
session: ChatSession | None = None
ui: SessionUI | None = None
worker_thread: threading.Thread | None = None
error_message: str = ""
last_active: float = field(default_factory=time.monotonic, repr=False)
notify_targets: str = "[]"
# Owning user_id. Populated by the SessionManager so attribution
# survives across restarts / lazy rehydration.
user_id: str = ""
# Classifier reused by both interactive and coordinator managers —
# no parallel type hierarchy.
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE
# Non-None for children spawned by a coordinator.
parent_ws_id: str | None = None
# The project this workstream is attached to (None = none). Children
# inherit the parent's project_id at spawn.
project_id: str | None = None
# Slug of the persona the workstream was created with ("" = pre-persona).
# Display carrier only — the session applies the stamped snapshot from
# workstream_config, never this field.
persona: str = ""
# Tombstone: set by ``SessionManager.close`` under ``_lock`` so a
# racing ``set_state`` can detect the close before it overwrites
# the persisted ``state='closed'`` row. Guarded by ``_lock``.
_closed: bool = field(default=False, repr=False)
# True while a worker thread is actively running ``ChatSession.send``.
# Toggled under ``_lock`` by ``turnstone.core.session_worker.send``
# (and the few sites that spawn workers directly — see
# ``server.py``'s init-message + retry-after-rewind paths) so
# concurrent dispatches can safely decide queue-vs-spawn without
# racing ``Thread.is_alive()``. Used by both interactive and
# coordinator paths since Stage 2 P1.
_worker_running: bool = field(default=False, repr=False)
# What KIND of work the current worker slot holds: "turn" (a send /
# retry / wake — the default) or "command" (a slash-command worker,
# including the minutes-long manual /compact). Written under
# ``_lock`` by ``session_worker.send`` in the same acquisition that
# sets ``worker_thread``/``_worker_running``, so readers gating on
# the running flag see a coherent triple. A stale value after the
# worker exits is harmless — every reader conjoins
# ``_worker_running``. The /send route defers (never queues) while
# this reads "command": the mid-turn interjection queue is
# turn-shaped (length cap, cross-user guard) and must be
# unreachable during command windows — deferred entries live on
# ``_pending_sends`` below.
worker_kind: WorkerKind = field(default="", repr=False)
# Sends deferred while the order barrier holds (full-fidelity
# pending entries — see :class:`_PendingSend` above), dispatched in
# arrival order by the per-workstream drain thread when the slot
# frees. Appends, retract-marks and claims all happen under
# ``_lock``. Node-local and in-memory: entries die with the
# workstream or the process (the interjection queue's lifetime) —
# the /send contract documents the at-most-once consequence.
_pending_sends: list[_PendingSend] = field(default_factory=list, repr=False)
# Single-flight guard for the drain (a daemon ``threading.Thread``
# while one is live). Written under ``_lock``; the drain clears it
# before exiting so a later deferred send starts a fresh one.
_pending_drain: threading.Thread | None = field(default=None, repr=False)
# True once ``SessionManager.commit_create`` (or the non-deferred
# path through ``SessionManager.create``) has fired the lifecycle
# ``emit_created`` event for this workstream. Used by
# :meth:`SessionManager.discard` (warns when set — abandoning an
# already-advertised ws leaves a stale ``ws_created`` on the wire
# with no matching ``ws_closed``) and by
# :meth:`SessionManager.commit_create` itself (no-ops when set,
# to make the idempotent-second-call and the
# commit-after-discard caller-bug paths safe). The non-deferred
# ``create`` sets this immediately before calling
# ``emit_created``; ``commit_create`` sets it under the manager
# lock alongside the tracked-ws check so a racing ``discard`` can
# never see it without also seeing the slot already popped.
_emit_created_fired: bool = field(default=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def __post_init__(self) -> None:
if not self.name:
self.name = f"ws-{self.id[:4]}"
def send_barrier_active(self) -> bool:
"""True while the /send order barrier holds — the ONE definition.
The barrier is a two-term pair, and every dispatch surface that
must not overtake acknowledged sends consults it here (the /send
route's defer probe, ``CoordinatorAdapter.send``'s refusal, the
queued-nudge wake gate) instead of hand-copying the terms:
* ``_pending_sends`` non-empty — acknowledged entries are waiting
(retract-marked husks count until the drain's loop-top purge:
they still occupy their arrival slot).
* the drain is alive — an entry may be CLAIMED (popped, dispatch
in flight); the :class:`_PendingSend` invariant ("drain not
alive ⇒ nothing claimed") is what makes these two terms
exhaustive, with no third state. The pair also covers the
drain-spawn window because ``_defer_send`` appends the entry
and starts the thread under one ``_lock`` acquisition — the
list term is always set before a not-yet-started drain could
be observed.
Lockless callers (the wake gate, the coordinator adapter) get
benign staleness in both directions: a stale True skips once
more and the next barrier-clearing path re-runs the gate (every
deferred turn's exit backstop, plus the drain's own clean exit);
a stale False means the concurrent defer holds no order contract
against the caller anyway. Callers that need the answer atomic
with a mutation (the route's probe-then-append) hold ``_lock``
around the call.
"""
drain = self._pending_drain
return bool(self._pending_sends) or (drain is not None and drain.is_alive())