mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-19 18:41:00 -06:00
376da3d084
* feat: prompt template tech debt — tests, read-only endpoints, double-load fix, server creation modal Close test coverage gaps for prompt templates: - Resume with deleted template: verifies graceful degradation (template_content=None, warning logged) - Threading safety: concurrent set_template/init_system_messages with no race conditions - Factory passthrough: template kwarg propagation through WorkstreamManager.create() Add read-only template listing endpoints (read scope, no content exposed): - GET /v1/api/templates — prompt template summaries (name, category, is_default, origin) - GET /v1/api/ws-templates — enabled workstream template summaries (name, description, model) - Available on both server and console; Python + TypeScript SDK methods added - Console creation modal switched from admin endpoint to read-scope endpoint Eliminate double-load inefficiency in workstream creation: - Template validation moved before mgr.create() (no create-then-rollback on invalid template) - template kwarg plumbed through WorkstreamManager.create() and session factory - _SessionFactory Protocol added for proper mypy typing Add workstream creation modal to server web UI: - Name, model, template dropdown, ws_template/profile dropdown - Instrument panel aesthetic: gradient top border, blur backdrop, amber accent - Focus trap, Escape/Enter keyboard handling, loading state, error display - WCAG AA contrast compliance, reduced-motion support * fix: add list_ws_templates SDK methods + regenerate OpenAPI snapshots Add list_ws_templates() to Python SDK (async + sync) and listWsTemplates() to TypeScript SDK for the new GET /v1/api/ws-templates server endpoint. Add WsTemplateSummary + ListWsTemplateSummaryResponse TypeScript types. Regenerate openapi-server.json and openapi-console.json snapshots. Addresses Copilot review feedback on PR #67. * fix: skip template pre-validation when resuming a workstream When resume_ws is set, the request's template field is irrelevant — resume() restores the template from workstream_config. Pre-validating a stale template name would incorrectly return 400 before the resume even runs. Addresses Copilot review feedback on PR #67.
360 lines
14 KiB
Python
360 lines
14 KiB
Python
"""Workstream manager — concurrent independent conversations.
|
|
|
|
A workstream is an independent conversation with its own ChatSession and UI
|
|
adapter. The WorkstreamManager coordinates multiple workstreams, tracks their
|
|
states, and lets frontends (CLI, Web) multiplex user attention across them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from typing import Protocol
|
|
|
|
from turnstone.core.session import ChatSession, SessionUI
|
|
|
|
class _SessionFactory(Protocol):
|
|
def __call__(
|
|
self,
|
|
ui: SessionUI | None,
|
|
model_alias: str | None = ...,
|
|
ws_id: str | None = ...,
|
|
*,
|
|
template: str | None = ...,
|
|
) -> ChatSession: ...
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
_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]}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manager
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class WorkstreamManager:
|
|
"""Manages multiple concurrent workstreams, each with its own ChatSession."""
|
|
|
|
def __init__(
|
|
self,
|
|
session_factory: _SessionFactory,
|
|
*,
|
|
max_workstreams: int = 10,
|
|
node_id: str | None = None,
|
|
):
|
|
"""
|
|
Args:
|
|
session_factory: callable(ui, model_alias, ws_id, *, template) -> ChatSession.
|
|
Captures shared config (registry, temperature, …) so the
|
|
manager can create ChatSession instances without knowing
|
|
those details. *model_alias* selects a model from the
|
|
registry (None = default). *ws_id* is the persistent
|
|
identity used for all storage operations.
|
|
max_workstreams: Maximum number of concurrent workstreams. When at
|
|
capacity, ``create()`` will auto-evict the oldest IDLE
|
|
workstream before raising.
|
|
node_id: Server node identity (persisted with workstreams).
|
|
"""
|
|
if max_workstreams < 1:
|
|
raise ValueError(f"max_workstreams must be >= 1, got {max_workstreams}")
|
|
self._session_factory: _SessionFactory = session_factory
|
|
self._node_id = node_id
|
|
self._max_workstreams: int = max_workstreams
|
|
self._workstreams: dict[str, Workstream] = {}
|
|
self._order: list[str] = [] # creation order
|
|
self._active_id: str | None = None
|
|
self._lock = threading.Lock()
|
|
self._on_state_change: Callable[[str, WorkstreamState], None] | None = None
|
|
self._evictions: int = 0
|
|
self._last_evicted: Workstream | None = None
|
|
|
|
@property
|
|
def eviction_count(self) -> int:
|
|
"""Number of workstreams auto-evicted by ``create()``."""
|
|
return self._evictions
|
|
|
|
@property
|
|
def last_evicted(self) -> Workstream | None:
|
|
"""The most recently evicted workstream, or ``None``."""
|
|
return self._last_evicted
|
|
|
|
# -- creation / destruction ---------------------------------------------
|
|
|
|
def create(
|
|
self,
|
|
name: str = "",
|
|
ui_factory: Callable[..., SessionUI] | None = None,
|
|
model: str | None = None,
|
|
template: str | None = None,
|
|
) -> Workstream:
|
|
"""Create a new workstream. Returns the new ws.
|
|
|
|
If the manager is at capacity, the oldest IDLE workstream is
|
|
automatically evicted. If **all** workstreams are non-idle, a
|
|
``RuntimeError`` is raised.
|
|
|
|
Args:
|
|
model: Optional model alias from the registry. ``None`` uses the
|
|
default model.
|
|
template: Optional prompt template name passed through to session
|
|
factory.
|
|
"""
|
|
# Fast-fail capacity check (avoids expensive ChatSession creation when full).
|
|
first_evicted: Workstream | None = None
|
|
with self._lock:
|
|
if len(self._workstreams) >= self._max_workstreams:
|
|
first_evicted = self._evict_oldest_idle_locked()
|
|
if first_evicted is None:
|
|
raise RuntimeError(f"All {self._max_workstreams} workstreams are active")
|
|
|
|
# Cleanup first-phase eviction outside the lock (may trigger callbacks).
|
|
if first_evicted is not None:
|
|
self._cleanup_ui(first_evicted)
|
|
self._last_evicted = first_evicted
|
|
from turnstone.core.metrics import metrics as _m1
|
|
|
|
_m1.record_eviction()
|
|
|
|
# Create workstream and ChatSession outside the lock (construction is
|
|
# expensive — involves LLM client setup and DB writes).
|
|
ws = Workstream(name=name)
|
|
if ui_factory:
|
|
ws.ui = ui_factory(ws.id)
|
|
ws.session = self._session_factory(ws.ui, model, ws.id, template=template)
|
|
|
|
# Authoritative insert under lock with re-check (another thread may
|
|
# have filled capacity while we were unlocked).
|
|
second_evicted: Workstream | None = None
|
|
with self._lock:
|
|
if len(self._workstreams) >= self._max_workstreams:
|
|
second_evicted = self._evict_oldest_idle_locked()
|
|
if second_evicted is None:
|
|
raise RuntimeError(f"All {self._max_workstreams} workstreams are active")
|
|
self._workstreams[ws.id] = ws
|
|
self._order.append(ws.id)
|
|
if self._active_id is None:
|
|
self._active_id = ws.id
|
|
|
|
# Persist to storage only after successful insertion
|
|
from turnstone.core.memory import register_workstream
|
|
|
|
register_workstream(ws.id, node_id=self._node_id, name=ws.name)
|
|
|
|
# Cleanup second-phase eviction outside the lock.
|
|
if second_evicted is not None:
|
|
self._cleanup_ui(second_evicted)
|
|
self._last_evicted = second_evicted
|
|
from turnstone.core.metrics import metrics as _m2
|
|
|
|
_m2.record_eviction()
|
|
return ws
|
|
|
|
# -- eviction helpers ---------------------------------------------------
|
|
|
|
def _evict_oldest_idle_locked(self) -> Workstream | None:
|
|
"""Find and remove the oldest IDLE workstream.
|
|
|
|
**Must be called while ``self._lock`` is held.** Returns the evicted
|
|
``Workstream`` (caller is responsible for UI cleanup) or ``None`` if no
|
|
IDLE workstreams exist.
|
|
"""
|
|
oldest: Workstream | None = None
|
|
for wid in self._order:
|
|
ws = self._workstreams[wid]
|
|
if ws.state == WorkstreamState.IDLE and (
|
|
oldest is None or ws.last_active < oldest.last_active
|
|
):
|
|
oldest = ws
|
|
if oldest is None:
|
|
return None
|
|
del self._workstreams[oldest.id]
|
|
self._order.remove(oldest.id)
|
|
if self._active_id == oldest.id:
|
|
self._active_id = self._order[0] if self._order else None
|
|
self._evictions += 1
|
|
return oldest
|
|
|
|
@staticmethod
|
|
def _cleanup_ui(ws: Workstream) -> None:
|
|
"""Unblock pending approval/plan/foreground events on a workstream."""
|
|
if ws.ui:
|
|
if hasattr(ws.ui, "_approval_event"):
|
|
ws.ui._approval_result = False, None # type: ignore[attr-defined]
|
|
ws.ui._approval_event.set()
|
|
if hasattr(ws.ui, "_plan_event"):
|
|
ws.ui._plan_result = "reject" # type: ignore[attr-defined]
|
|
ws.ui._plan_event.set()
|
|
if hasattr(ws.ui, "_fg_event"):
|
|
ws.ui._fg_event.set()
|
|
# Notify SSE listeners so generators exit promptly
|
|
if hasattr(ws.ui, "_listeners_lock"):
|
|
import contextlib
|
|
import queue as _queue
|
|
|
|
with ws.ui._listeners_lock:
|
|
for lq in ws.ui._listeners: # type: ignore[attr-defined]
|
|
try:
|
|
lq.put_nowait({"type": "ws_closed"})
|
|
except _queue.Full:
|
|
with contextlib.suppress(_queue.Empty):
|
|
lq.get_nowait()
|
|
with contextlib.suppress(_queue.Full):
|
|
lq.put_nowait({"type": "ws_closed"})
|
|
ws.ui._listeners.clear() # type: ignore[attr-defined]
|
|
# Release MCP listener registration
|
|
if ws.session and hasattr(ws.session, "close"):
|
|
ws.session.close()
|
|
|
|
def close(self, ws_id: str) -> bool:
|
|
"""Close a workstream. Returns False if it's the last one."""
|
|
with self._lock:
|
|
if len(self._workstreams) <= 1:
|
|
return False
|
|
ws = self._workstreams.pop(ws_id, None)
|
|
if ws is None:
|
|
return False
|
|
self._order.remove(ws_id)
|
|
if self._active_id == ws_id:
|
|
self._active_id = self._order[0]
|
|
# Unblock any waiting approval/plan events so worker thread can exit
|
|
self._cleanup_ui(ws)
|
|
from turnstone.core.memory import update_workstream_state
|
|
|
|
update_workstream_state(ws_id, "closed")
|
|
return True
|
|
|
|
# -- lookup -------------------------------------------------------------
|
|
|
|
def get(self, ws_id: str) -> Workstream | None:
|
|
with self._lock:
|
|
return self._workstreams.get(ws_id)
|
|
|
|
@property
|
|
def active_id(self) -> str | None:
|
|
return self._active_id
|
|
|
|
def get_active(self) -> Workstream | None:
|
|
with self._lock:
|
|
return self._workstreams.get(self._active_id) if self._active_id else None
|
|
|
|
def list_all(self) -> list[Workstream]:
|
|
"""Return workstreams in creation order."""
|
|
with self._lock:
|
|
return [self._workstreams[wid] for wid in self._order if wid in self._workstreams]
|
|
|
|
def index_of(self, ws_id: str) -> int:
|
|
"""1-based index of a workstream, or 0 if not found."""
|
|
with self._lock:
|
|
try:
|
|
return self._order.index(ws_id) + 1
|
|
except ValueError:
|
|
return 0
|
|
|
|
@property
|
|
def count(self) -> int:
|
|
with self._lock:
|
|
return len(self._workstreams)
|
|
|
|
# -- switching ----------------------------------------------------------
|
|
|
|
def switch(self, ws_id: str) -> Workstream | None:
|
|
"""Switch active workstream. Returns new active or None."""
|
|
with self._lock:
|
|
if ws_id in self._workstreams:
|
|
self._active_id = ws_id
|
|
return self._workstreams[ws_id]
|
|
return None
|
|
|
|
def switch_by_index(self, index: int) -> Workstream | None:
|
|
"""Switch by 1-based index (creation order)."""
|
|
with self._lock:
|
|
if 1 <= index <= len(self._order):
|
|
ws_id = self._order[index - 1]
|
|
self._active_id = ws_id
|
|
return self._workstreams.get(ws_id)
|
|
return None
|
|
|
|
# -- state management ---------------------------------------------------
|
|
|
|
def set_state(self, ws_id: str, state: WorkstreamState, error_msg: str = "") -> None:
|
|
"""Update a workstream's state. Called by UI adapters."""
|
|
ws = self._workstreams.get(ws_id)
|
|
if ws:
|
|
with ws._lock:
|
|
ws.state = state
|
|
ws.last_active = time.monotonic()
|
|
ws.error_message = error_msg
|
|
from turnstone.core.memory import update_workstream_state
|
|
|
|
update_workstream_state(ws_id, state.value)
|
|
if self._on_state_change:
|
|
self._on_state_change(ws_id, state)
|
|
|
|
def close_idle(self, max_age_seconds: float) -> list[str]:
|
|
"""Close IDLE workstreams inactive for more than *max_age_seconds*.
|
|
|
|
Skips the last workstream and any workstream not in IDLE state.
|
|
Returns a list of closed ws_ids.
|
|
"""
|
|
now = time.monotonic()
|
|
with self._lock:
|
|
snapshot = list(self._workstreams.values())
|
|
expired = sorted(
|
|
[
|
|
ws
|
|
for ws in snapshot
|
|
if ws.state == WorkstreamState.IDLE and (now - ws.last_active) > max_age_seconds
|
|
],
|
|
key=lambda ws: ws.last_active, # oldest first
|
|
)
|
|
# Never leave zero workstreams
|
|
max_closeable = max(0, len(snapshot) - 1)
|
|
to_close = [ws.id for ws in expired[:max_closeable]]
|
|
|
|
closed = []
|
|
for ws_id in to_close:
|
|
ws = self._workstreams.get(ws_id)
|
|
# Re-check state to guard against race between collection and close
|
|
if ws and ws.state == WorkstreamState.IDLE and self.close(ws_id):
|
|
closed.append(ws_id)
|
|
return closed
|