Compare commits

..

76 Commits

Author SHA1 Message Date
Patrick Buckley fd5b710437 chore: bump version to 1.7.0a2 2026-06-16 04:39:26 -07:00
Patrick Buckley e562d04e8b fix(deps): enforce cryptography + starlette security floors
main's lockfile was already on the patched versions (cryptography 49.0.0,
starlette 1.3.1) via renovate, but the pyproject floors (>=42, >=1.0.1) still
permitted a regression to vulnerable versions. Raise the floors to match
stable/1.6's v1.6.7 security fix:
- cryptography >=48.0.1 (GHSA-537c-gmf6-5ccf — bundled OpenSSL vulnerable <48.0.1)
- starlette >=1.3.1 (CVE-2026-54282 host spoof + CVE-2026-54283 url-encoded form DoS)
2026-06-16 04:39:26 -07:00
Patrick Buckley f714e49e02 fix(voice): default blank provider to openai in the admin audio gate
Copilot review: _audioModelEligible gated stt/tts on md.provider, but a
blank/unset provider was treated as not-audio-capable and excluded — an
asymmetry with the backend, where _provider_carries_audio and
ModelConfig.provider both default to "openai". Default the provider to "openai"
before the check so a provider-less model isn't wrongly dropped from the
voice-role dropdowns.
2026-06-16 03:41:51 -07:00
Patrick Buckley f4bab9fe16 refactor(attachments): retire the vestigial reservation scaffolding
The by-ref change replaced the send_id reservation model with the per-node
upload buffer (peek-then-drain at write time), but the surrounding narration was
never swept and a no-op stub was retained to make the send handler "read like"
the old flow — which is what made a recent diagnosis assume reservations still
existed.

- Delete the no-op _release_reservation_on_fail() and its 5 call sites in the
  send handler (behaviour-preserving — it did nothing).
- Rename ordered_reserved / reserved_set -> ordered_taken / taken_set (the values
  are the "taken" subset from resolve_staged_attachments, not reservations).
- Sweep the stale "reserve/reservation" wording across the create/send
  docstrings, the API schemas/specs, and the SDK docstrings to the staged-buffer
  vocabulary (resolve / attach / drain). The canonical docs in attachment_buffer
  and attachments already stated the reservation token is gone.

No behaviour change; no tests exercised the removed scaffolding (the
migration-060 test correctly pins the reserved_at column removal and stays).
2026-06-16 03:41:51 -07:00
Patrick Buckley b8a8b04042 fix(attachments): drain create-time staged uploads synchronously
A create-time attachment is dispatched on the first turn, but the buffer drain
runs at write time inside the async dispatch worker (_append_user_turn). The
freshly-opened pane calls rehydrate() before the worker drains, so it painted
the image as a still-pending composer chip ("thumbnail in the text input box").

The inlined first-turn dispatch is the only consumer of those staged uploads and
always commits at create, so drain them from the buffer synchronously right
after resolving them — both the interactive and coordinator post-install paths.
The worker's own per-id discard then no-ops.
2026-06-16 03:41:51 -07:00
Patrick Buckley d6f9e6f7d3 fix(voice): gate audio roles to OpenAI-SDK providers
An omni model registered via the anthropic-compatible lane (vLLM Messages API)
was offered for the STT role because it carries supports_audio_input — but the
Anthropic SDK client has no .chat.completions and the Messages API has no audio
content block, so the mic failed with a cryptic
"'Anthropic' object has no attribute 'chat'".

Audio (input_audio) only rides the OpenAI-SDK surface, so gate all audio roles
to OpenAI-SDK providers (openai / openai-compatible / google / xai):
- model_supports_role returns False for anthropic(-compatible), so the mic
  won't draw and the STT/TTS dropdowns won't offer those models.
- transcribe() raises a clear AudioUnavailableError naming the provider instead
  of the opaque AttributeError (defence in depth).
- admin _audioModelEligible mirrors the gate — voice roles only; reranker hits a
  /rerank endpoint, not audio, so it stays un-gated.

To use an omni model's audio, register it as openai-compatible (the input_audio
path); the anthropic-compatible lane is text/vision only.
2026-06-16 03:41:51 -07:00
Patrick Buckley 531913ec03 refactor(attachments): address branch self-review
- DRY the launcher create body: the multipart (meta + file parts) vs JSON
  framing was duplicated in _createCoordinator and _createInteractive — extract
  _createWorkstreamFetchOpts so the create wire shape lives in one place.
- Correct the proxy comment: the forwarded owner uid comes from the
  authenticated ws_body (as on the JSON path), not the caller's meta; the proxy
  token source is console-proxy, not console.
2026-06-16 03:41:51 -07:00
Patrick Buckley 2568ea5691 feat(voice): let omni models serve speech-to-text via the chat path
The mic is an STT control — it records and transcribes to editable text in the
composer. STT eligibility required the dedicated /audio/transcriptions endpoint
(supports_transcription / a whisper-style name), so an omni chat model
(supports_audio_input, e.g. Gemma) couldn't back it: it has no transcription
endpoint, it ingests audio via chat.

- model_supports_role accepts supports_audio_input for the STT role, so an omni
  alias resolves as STT and the mic draws for it.
- transcribe() branches: a whisper-style alias keeps /audio/transcriptions; an
  omni alias transcribes via chat input_audio + an instruction prompt — the
  audio.stt_prompt override, else a default that emits only the transcript.
  Audio attachments on an omni-STT setup transcribe the same way.
- admin _audioModelEligible mirrors the eligibility so omni models show in the
  STT dropdown; the role description notes the two backends.
2026-06-16 03:41:51 -07:00
Patrick Buckley 0171a9dd18 fix(attachments): normalize EXIF orientation so thumbnails and models see upright images
Phone photos store landscape pixels plus an EXIF orientation tag. Browsers honour
the tag for <img>, but Pillow (our thumbnails) and many vision-model image
decoders do not — so the thumbnail rendered rotated AND the model literally
perceived the photo sideways (noticed earlier as model "hallucinations", before
thumbnails made the rotation visible).

Normalize on read, at both surfaces:
- new core/images.normalize_image_orientation: bakes the rotation into the pixels
  and re-encodes (preserving format); images with no / identity orientation pass
  through untouched (pristine original, no per-send cost).
- make_thumbnail applies exif_transpose — after the decompression-bomb pixel gate,
  which now also covers the transpose decode.
- attachment_to_content_part runs image bytes through the normalizer before
  base64, so the primary model and the perception model both get upright pixels.

Because normalization is on read (not at upload), it fixes already-stored uploads
too.
2026-06-16 03:41:51 -07:00
Patrick Buckley 793c5518cc fix(attachments): surface the perception role in admin (roles tab + settings filter)
The universal perception fallback (perception.model_alias) shipped backend-only
— session.py + perception.py + settings_registry.py — so its admin UI was never
wired. Operators had no way to assign it from the Models → Roles sub-tab, and the
raw setting leaked into the Settings tab.

- Add a Perception row to MODEL_ROLES (no capability filter — it spans
  image/PDF/audio; the description tells operators to enable supports_vision /
  supports_audio_input on the target model, which is what makes the audio
  fallback engage when no STT role is set).
- Derive the Settings role-key skip-set from MODEL_ROLES instead of a
  hand-maintained list, so perception is filtered out and no future role can
  drift back in (stt/tts/reranker had leaked the same way).
- Add an optional per-role disabledLabel so the blank dropdown option reads
  correctly for non-voice roles (perception, reranker) instead of "voice off".
- Refresh the stale STT description that claimed "no audio-capable session
  fallback" — audio attachments now fall back to perception.
2026-06-16 03:41:51 -07:00
Patrick Buckley 9c15bb035c fix(attachments): forward create-time attachments for console interactive sessions
The console creates interactive sessions by proxying to the owning node via
/v1/api/cluster/workstreams/new, which only forwarded JSON — so a file staged in
the launcher was blocked with "Attachments aren't supported for interactive
sessions yet". The node create endpoint already accepts multipart (meta JSON +
file parts) on interactive_endpoint_config; only the proxy lacked it.

Teach create_workstream to accept multipart: parse meta + files (same caps as
the node), pick the node exactly as before (auto / pool / pinned), and forward
the files instead of re-serialising JSON. _createInteractive sends multipart
when files are staged (mirroring _createCoordinator) and the launcher gate is
removed. The files-need-a-task guard already ensures an initial turn to
dispatch them on.
2026-06-16 03:41:51 -07:00
Patrick Buckley f7500261e2 fix(attachments): base-prefix interactive pane attachment requests
A console interactive pane is node-proxied — every request rides the pane's
transport base ("/node/{id}"). The attachment controller hardcoded bare
/v1/api/workstreams/... paths, so upload / list / delete / preview landed on
the console's OWN coord route, which resolves ws_id via coord_mgr.get() and
404s as "coordinator not found". The standalone server (base="") was
unaffected, which masked the bug.

Thread the pane base through: createAttachmentController and
buildAttachmentPreview take an optional getBase / base, and the interactive
pane wires this._base into both. Coordinator panes and the standalone server
pass "" and stay origin-mounted as before.
2026-06-16 03:41:51 -07:00
Patrick Buckley 8e05c10b78 fix(attachments): address PR review feedback (Copilot + code-quality)
- TextDecoder in the text-preview stream now flushes on completion/cancel, so a multibyte UTF-8 char split across a chunk boundary isn't dropped (Copilot).

- send() clears self._wire_part_cache in a finally so the per-send memo (which can hold large rasterized PDF page-images) is released at send end instead of retained on an idle session until the next send (Copilot + fix-review).

- Make the implicit byte-string concatenation in _minimal_pdf explicit (+) in test_pdf.py and test_thumbnails.py so it can't read as a missing comma (CodeQL / github-code-quality).
2026-06-16 00:48:14 -07:00
Patrick Buckley ed5c104a88 fix(attachments): address fix-review nits (ftyp scan, text-preview, cache doc)
A review of the fix commits surfaced three refinements:

- ftyp audio sniff: scan the whole ftyp box (its declared length) for an audio brand instead of a fixed 6-slot window, so a real .m4a with the brand listed late still passes — while a pure-video file (no audio brand) still rejects.

- text-preview: accumulate body chunks until >=240 chars before cancelling the stream, instead of assuming the first chunk is large (flush boundaries can split a large body into small early chunks).

- _resolve_attachments: correct the cache comment — the memo is refreshed per send and the wire resolver only runs during a send, so a stale value is never observed between sends.
2026-06-16 00:48:14 -07:00
Patrick Buckley dd80ca3655 chore(attachments): hygiene sweep — dead code, stale comments, SDK type, pdf nit
- Remove the unused PerceptionUnavailableError (never raised/caught/imported).

- Reword the now-shipped 'Phase 3' placeholder comments on the Anthropic + OpenAI-Responses audio paths to describe the live upstream STT/perception fallback (these placeholders are defensive, not pending work).

- Clarify the no-vision image fall-through comment (fires when perception is unconfigured OR can't see, not only the former).

- Type AttachmentInfo.kind as the image|text|pdf|audio union in the TS SDK.

- extract_pdf_text: append the truncation marker only when there's actual text, so a scanned PDF over the page cap returns '' (-> placeholder) instead of a content-free document part.
2026-06-16 00:48:14 -07:00
Patrick Buckley f7cba67c2c test(attachments): handler-level coverage for /thumbnail + the served-blob gate
The /thumbnail endpoint and the _resolve_served_blob ownership/404-leak gate it shares with /content had no handler-level test (only make_thumbnail as a unit + route mounting). Add cases through the real app: image -> 200 image/png with the nosniff + CSP + max-age headers; audio/text -> 415; make_thumbnail None -> 415; cross-workstream id and unowned-ws cross-user -> 404 (no existence leak).
2026-06-16 00:48:14 -07:00
Patrick Buckley 07e7e6db2f perf(attachments): stop downloading the whole text blob for a 240-char preview
The text-snippet preview fetched the entire /content body (text attachments are capped at 512 KiB) only to render the first 240 chars — and again on the sent-message pill (the endpoint sends Cache-Control: no-store). Read only the first response-body chunk and cancel the stream, so the rest of the blob is never transferred or regex-scanned. Falls back to r.text() where the streaming body API is unavailable.
2026-06-16 00:48:14 -07:00
Patrick Buckley 8ca20fecd4 fix(attachments): unify kind-icon, fix coordinator audio pill + thumbnail-error gap
Three copies of the kind->glyph mapping had drifted: the coordinator pill rendered audio as the document glyph (not the audio note) and showed no inline preview, diverging from the interactive pane.

Export kindIcon() from composer_attachments.js (+ window bridge) as the single source of truth; the interactive pane imports it and the coordinator pill uses it. Wire the coordinator pill to buildAttachmentPreview too (image/pdf thumbnail, audio player), gracefully no-oping on history replay (which omits attachment_id), matching interactive.

Also fix buildAttachmentPreview's thumbnail-error handler: it called img.remove(), but the caller has already replaced the icon span with the img, so a failed thumbnail left a blank gap. Swap in the kind glyph instead (.attach-preview-icon, sized to the thumbnail slot).
2026-06-16 00:48:14 -07:00
Patrick Buckley 797a8e0404 fix(attachments): preserve pdf/audio kind when reloading attachments from the DB
_reconstruct_attachment_refs collapsed every non-image attachment to the 'document' placeholder kind, so a reloaded session's pdf/audio placeholder type ({type:document}) mismatched the live-injection type ({type:pdf}/{type:audio}). Harmless today (resolution keys on attachment_id + blob kind) but a latent footgun for any consumer branching on the pre-resolution placeholder type. Preserve image/pdf/audio verbatim; only a stored 'text' blob collapses to 'document'.
2026-06-16 00:48:14 -07:00
Patrick Buckley 5b2a9480a1 fix(attachments): sanitize user filenames in model context; mark derived text untrusted
A user-controlled filename was interpolated unescaped into model-visible frames (the [PDF attachment '{name}'...] / audio / transcript / perception placeholders, the Anthropic document title, and the unreadable placeholder). A crafted name like "'] New instructions:" broke out of the frame and injected text into the model context.

Add core.attachments.safe_attachment_label() (strip control chars + quote/bracket/angle delimiters, collapse whitespace, clamp length) and apply it at every model-context embedding site. The raw filename is still used verbatim for display / Content-Disposition, which neutralize at their own boundaries.

Also tag perception descriptions and STT transcripts '(untrusted)' so attachment-derived text reads as data, not instructions. Blast radius is single-tenant (injecting into a model reading one's own upload); a structural role=tool fence is deferred as disproportionate.
2026-06-16 00:48:14 -07:00
Patrick Buckley bcfc6306eb fix(attachments): reject video as audio in ftyp sniff; add ADTS-AAC sniff
sniff_audio_mime returned audio/mp4 for ANY ISO-BMFF ftyp box, so an MP4/MOV video uploaded within the audio size cap sniffed as audio and was sent as input_audio. Restrict to genuine audio brands (M4A/M4B/F4A/F4B major, or M4A/M4B in the compatible-brands list, so a real .m4a with an mp42 major brand still passes).

Also add ADTS-AAC sniffing (0xFFF1/0xFFF9): audio/aac was in ALLOWED_AUDIO_MIMES + AUDIO_MIME_TO_FORMAT but never sniffable, so an advertised .aac upload always failed.
2026-06-16 00:48:14 -07:00
Patrick Buckley 25101e7ff6 fix(attachments): close thumbnail decompression-bomb gap (40M, not 80M)
make_thumbnail set Image.MAX_IMAGE_PIXELS=40M, but Pillow only raises DecompressionBombError above 2x the cap; a 40-80M px image merely warns and decodes fully (~480MB RGB), defeating the documented bound.

Gate on the header-declared size after open() and before convert(), so nothing past the cap is decoded. Explicit check rather than a warnings filter — make_thumbnail runs in a worker thread and global warnings state is not thread-safe. Adds tests for the (cap, 2*cap] warn-only window and the at-cap boundary.
2026-06-16 00:48:14 -07:00
Patrick Buckley 7da07e2350 perf(attachments): per-send wire-part memo to stop re-rasterizing every round-trip
_resolve_attachments re-runs on every agentic round-trip (and per fallback model), each time re-fetching every attachment across the full history and re-rasterizing / re-base64'ing it. A 10-page PDF in a 10-cycle tool turn was rendered dozens of times.

Add a per-send memo (self._wire_part_cache) keyed by (attachment_id, caps-signature): the materialized wire part is computed at most once per send. The cache is None outside a send (display/export paths unaffected) and reset per send to bound the heavy rasterized-page parts and pick up any mid-session capability change. Skip the DB fetch entirely when every id is already cached.

Also peek the perception (alias, content_hash) memo before building parts in _perception_fallback_part, so a cross-send describe hit no longer wastes a PDF rasterize. Leaves pdf.py's deliberate no-module-cache stance intact — the per-send scope addresses the round-trip amplification without the durable store it defers.

Adds describe_peek() + per-send-cache and peek tests.
2026-06-16 00:48:14 -07:00
Patrick Buckley d5d9db39d4 fix(attachments): repair dead OpenAI-Responses native PDF path
sanitize_messages ran inline_document_parts (which placeholders an application/pdf document part) before the Responses translator's native input_file branch could run, so every supports_pdf model silently degraded its PDF to an unsupported text placeholder.

Thread a skip_pdf_inline flag through sanitize_messages -> inline_document_parts; the Responses lane sets it so the PDF document survives to convert_content_parts. Chat / Google-compat keep the placeholder (they have no native PDF block).

The existing test exercised convert_content_parts in isolation, bypassing sanitize_messages and masking the bug. Add an end-to-end _convert_messages regression test (verified to fail without the fix) plus contrast tests pinning both lanes' behavior.
2026-06-16 00:48:14 -07:00
Patrick Buckley a3cb546030 docs(attachments): pin xAI Grok to the rasterize-PDF fallback
q-3 from the pre-push review, settled against docs.x.ai: Grok's document
support is an agentic attachment_search workflow over Files-API uploads
(file_id / file_url), not the inline base64 native ingestion that OpenAI
input_file / Anthropic document blocks use. Our native PDF path emits inline
base64, which xAI's Responses surface doesn't accept — so supports_pdf is
correctly left unset (Grok PDFs rasterize to images, which Grok can see).

Document the rationale on GROK_CAPABILITIES and pin every Grok row's
supports_pdf=False with a test so it isn't naively flipped without first
wiring a Files-API upload flow.
2026-06-16 00:48:14 -07:00
Patrick Buckley 558ddadc79 feat(attachments): universal perception fallback for non-native modalities
Add a `perception.model_alias` model role: when the primary model can't ingest
an attachment natively and can't be shown a degraded-but-native form, a
configured perception model perceives it and its output is carried as text.
Mirrors the STT role — a role alias plus a module-level memo so the extra LLM
round-trip runs once per attachment, not once per conversation turn. The call
goes through the provider abstraction's create_completion (the path the intent
judge uses), so any vision/omni provider works.

Bottom-tier, universal ladder — perception only fills the remaining gap:
- pdf  : native supports_pdf -> rasterize-to-vision-primary -> perception
         -> extracted text -> placeholder
- image: native vision -> perception (non-vision primary) -> native image_url
- audio: native supports_audio_input -> STT -> perception (omni) -> placeholder

Folds in two review findings the role subsumes:
- bug-1: thread the active attempt's capabilities into _resolve_attachments
  (bound in _try_stream) so a model fallback materializes attachments against
  the fallback model's caps, not the primary's.
- bug-2: charge a by-reference pdf/audio a bounded budget min(size_bytes, 16K)
  instead of zero, so a large-attachment turn isn't budgeted as ~empty (the
  exact materialized size isn't known until wire build).
2026-06-16 00:48:14 -07:00
Patrick Buckley 8af3e21dff fix(attachments): harden thumbnail/rasterize DoS + review nits
Pre-push review follow-ups that are independent of the perception-role work
(bug-1 caps threading, bug-2 budget, and the perf cluster fold into that):

- thumbnails: cap decoded pixels (Image.MAX_IMAGE_PIXELS=40M) so a small
  compressed image that decodes to huge dimensions can't OOM the node, and
  reject DecompressionBombError cleanly.
- pdf: clamp per-page render scale so the longest rendered side stays <= 2000px
  (a maximal MediaBox at scale 2.0 rendered to a ~28800px, multi-GB bitmap).
- session_routes: type classify_upload's rejection element as
  UploadRejection | None instead of Any.
- test_session_routes: assert the /thumbnail route mounts (it was untested) and
  fix the stale "quartet"/four wording to five.
2026-06-16 00:48:14 -07:00
Patrick Buckley 9ad447ca33 fix(attachments): design-review polish for preview chips/pills
Two-reviewer + sanity pass over the attachment previews:

- composer audio chip is icon+name+size only; the native <audio> player
  renders on the sent message, not the staging chip (too heavy at chip scale)
- cap sent-message pills (+ in-pill audio/snippet) so they no longer overflow
  the bubble at narrow widths; player and snippet drop to their own row
- clamp the chip filename in shared chat.css so long names ellipsize instead
  of wrapping (console main + coordinator previously left it unclamped)
- merge the duplicated .composer-chip rule; drop unused kind-modifier classes
  and inert vertical-align / inline-block declarations
- fix undefined var(--bg-base) -> var(--bg-surface) thumbnail backing
- label the <audio> control (aria-label) and drop the decorative snippet from
  the a11y tree

scripts/livepass.py: add an attachments harness that drives the real
createAttachmentController + Pane.addUserMessage so these surfaces render
headlessly for review.
2026-06-16 00:48:14 -07:00
Patrick Buckley c09ba6041f feat(attachments): inline chip previews (image/pdf thumbnail, audio player, text snippet)
- core/thumbnails.py + GET .../attachments/{id}/thumbnail: server-rendered PNG
  thumbnails (image downscale; pdf first page via pypdfium2). Extracted a shared
  ownership-gated blob resolver used by both get_content and the thumbnail route
- buildAttachmentPreview (composer_attachments.js): image/pdf -> thumbnail,
  audio -> <audio> player, text -> lazy snippet; reused by the composer chips and
  the sent-message pills (interactive.js). Cookie auth, so direct media src works
- chip kind icons now cover pdf/audio; the upload swap adopts the server's
  authoritative kind for styling + icon + preview
- chat.css preview styling; tests for make_thumbnail
2026-06-16 00:48:14 -07:00
Patrick Buckley 8ad6d3d2f3 feat(attachments): accept pdf/audio uploads in the UI + admin capability toggles
- composer: accept pdf/audio in the upload picker; client-side kind
  inference for the optimistic chip (server classify_upload stays
  authoritative)
- admin Models tab: supports_pdf + supports_audio_input toggles (flow
  through the field-aware capabilities merge into ModelCapabilities, so
  flipping supports_audio_input on an omni alias enables native input_audio)
- docs: AttachmentInfo.kind, AttachmentUpload, and the TS SDK note pdf/audio
2026-06-16 00:48:14 -07:00
Patrick Buckley 471d94b27f feat(attachments): rasterize PDF to page images for vision models without native PDF
A vision-capable model that can't ingest PDF natively now gets the PDF
rendered to one image per page instead of extracted text; falls back to
text extraction when rendering yields nothing.

- core/pdf.py: rasterize_pdf via pypdfium2 render + Pillow PNG (page-capped
  at 10, never raises)
- session._wire_content_part: pdf + !supports_pdf + supports_vision ->
  rasterized image parts; else text extraction
- trajectory.resolve_attachment_parts: a placeholder can now expand to a
  list of parts (1->N); the resolve_attachments callback return type widened
  to dict[str, Any] across the provider protocol + 4 providers
- pyproject: pillow dependency
- tests: rasterize_pdf, vision-rasterize gate path, 1->N materialization
2026-06-16 00:48:14 -07:00
Patrick Buckley addb8d0be8 feat(attachments): capability-gated client-side fallback (pdf->text, audio->transcript)
When the active model can't ingest a kind natively, the wire resolver
converts it client-side instead of sending a part the model can't read.
Per-kind ownership, no shared machinery: PDF text-extraction is a
pure-local PDF concern; audio transcription is an STT concern memoized
in the audio domain.

- core/pdf.py: extract_pdf_text via pypdfium2 (pure-local, no network, no
  cache — re-run per build; page-capped)
- core/audio.py: transcribe_cached — non-raising, memoized by
  (alias, content-hash); backend failures not cached
- session._wire_content_part: per-kind dispatch — native where the model
  supports the kind (supports_pdf / supports_audio_input), else fallback;
  display/export resolve natively so no conversion fires on a render
- image left ungated (pre-existing behavior unchanged)
- pyproject: pypdfium2 dependency + mypy untyped-import override
- tests: pdf extraction, transcript memoization, per-kind gate dispatch
2026-06-16 00:48:14 -07:00
Patrick Buckley 701ae46c72 feat(attachments): native PDF + audio translators, accept on upload
PDF and audio attachments now work end-to-end on the native provider
lanes; non-native lanes degrade to a placeholder (client-side fallback
lands next). Capability flags are populated but not yet consumed by a
wire-build gate.

- providers: Anthropic PDF -> base64 document; OpenAI Responses PDF ->
  input_file; compat/Google inline_document_parts PDF -> placeholder
  (fixes the base64-as-text mangle); audio = input_audio passthrough on
  the compat lane (omni), defensive text placeholders on Anthropic +
  Responses
- capabilities: supports_pdf on cloud Claude + OpenAI chat models;
  local/default/compat stay False (-> client-side fallback)
- upload: classifier accepts pdf (32 MiB) + audio (25 MiB); endpoint
  multipart read cap raised to PDF_SIZE_CAP
- hygiene: consolidate the duplicated upload classification into one
  attachments.classify_upload (+ UploadRejection); collapse
  AttachmentUploadHelpers to a single classify_upload callable
- tests: PDF/audio translator shapes, capability flags, classify_upload
2026-06-16 00:48:14 -07:00
Patrick Buckley 129560ee60 feat(attachments): pdf + audio attachment kinds (dormant spine)
Provider-neutral plumbing for PDF and audio attachments, with no
user-facing change yet: the upload classifier still rejects them and the
capability tables stay unpopulated (both land in the native-translator
phase). No migration — workstream_attachments.kind is free-text.

- attachments.py: PDF/audio byte caps, allowed-audio MIMEs + format map,
  magic-byte sniffers (sniff_pdf_mime / sniff_audio_mime),
  Attachment.is_pdf / is_audio
- providers/_protocol.py: supports_pdf / supports_audio_input capability
  fields (default False; orthogonal to the STT/TTS roles)
- storage/_utils.py: attachment_to_content_part emits the internal
  document(application/pdf, base64) and input_audio shapes
- session.py: by-reference placeholder branches for pdf / audio
- trajectory.py: AttachmentRef docstring (dict-bridge already kind-agnostic)
- tests: test_attachments_pdf_audio.py
2026-06-16 00:48:14 -07:00
Patrick Buckley 04b3a3abe4 feat(deploy): systemd units for a bare-metal turnstone-server node
Hardened service + slice + node-identity drop-in template + a README for
running a turnstone-server outside Docker that joins the compose cluster —
the production-shaped counterpart to the one-liner in docs/docker.md. Secrets
stay in config.toml; per-host identity + cluster URLs go in the drop-in. The
README notes the cross-host mTLS caveat (turnstonelabs/lacme#22).
2026-06-15 03:41:24 -07:00
Patrick Buckley 1f61350545 feat(compose): let bare-metal turnstone-servers join the cluster (incl. mTLS)
A turnstone-server running outside the compose network ("bare-metal", e.g. a
local-GPU box) couldn't fully join: it can't resolve the in-cluster console
(console:8090) to enroll its mTLS cert, and SearxNG was unreachable for
web_search. Only Postgres was published.

Publish the console's plain-HTTP ACME endpoint (:8090) and SearxNG (:8081)
alongside Postgres, all bound via one knob TURNSTONE_HOST_IP (default 127.0.0.1
-- nothing new on the LAN; set it to the host's LAN IP for a node on another
machine). Postgres keeps honoring the legacy POSTGRES_BIND as a fallback, so
existing .env files don't break.

The node's TLS client now honors TURNSTONE_CONSOLE_URL so a bare-metal node can
point at the published ACME endpoint instead of the unreachable in-cluster name
(empty = in-cluster service discovery, unchanged).

Docs (docker.md, tls.md), the run.sh-generated .env, and the bootstrap wizard
updated to match. The advertised host is the cert's primary SAN and the console
collector dials it back, so mTLS hostname verification holds both ways.
2026-06-15 03:41:24 -07:00
renovate[bot] 94e385e91f chore(deps): lock file maintenance 2026-06-15 02:49:32 -07:00
Patrick Buckley 108714a48d fix(auth): isolate server/console session cookies by name
The server (:8080) and console (:8090) both set a cookie named
`turnstone_auth`. Cookies ignore port (RFC 6265), so on a shared host
(localhost dev, the Electron build, single-box installs) logging into one
surface overwrote the other's cookie and 401'd the first session.

Give each surface its own cookie name -- `turnstone_auth_server` /
`turnstone_auth_console` -- threaded as a required `cookie_name` argument
through the cookie builders, `check_request`, `AuthMiddleware`, and the six
shared auth handlers (login/logout/setup/whoami/refresh/oidc_callback). Each
app passes its own constant; the parameter is required (no default) so a
forgotten caller fails loudly instead of silently reverting to the legacy name.

Names key on role, not node: the cluster shares one JWT identity and the
console->node proxy re-mints a bearer token (dropping Set-Cookie), so
per-instance names would break identity portability and aren't used.

Hard cutover: the legacy `turnstone_auth` cookie is no longer read and
self-expires within its 24h TTL (one forced re-login). JWT audience was
already enforced, so the shared cookie was a session clobber, not an auth
bypass.
2026-06-15 02:48:50 -07:00
Patrick Buckley a628e9f3b4 fix(ui): interactive pane keeps its scroll pin across tool calls
The interactive pane only auto-scrolled when isNearBottom() was true, but it measured that AFTER the new node was appended. A tool block is a tall one-shot append (batch shell, approval card, or result) that clears the 80px near-bottom threshold in a single step, so the post-append check read false and auto-follow silently disengaged at exactly tool-call time — the view froze at the top of the block and only snapped back at the next stream_end. Token streaming was unaffected because each append stays sub-threshold.

Capture the near-bottom state as the first statement of each tool-render method, before any DOM mutation, and thread it into scrollToBottom(stick). This re-pins when the user was already at the bottom and, unlike the coordinator pane's unconditional pin, leaves the view alone if they deliberately scrolled up while a result was rendering.

Methods fixed: announceToolBlock, showInlineToolBlock, resolveApproval, appendToolOutput (all three exit paths), appendToolOutputChunk.
2026-06-13 06:15:49 -07:00
Patrick Buckley 1468ca7972 fix(examples): accept remote Host headers when bound off localhost
The streamable-http server bound to 0.0.0.0/a LAN IP answered TCP and
/watch but returned 421 "Invalid Host header" on /mcp for every remote
node — which broke multi-node play entirely. FastMCP freezes DNS-rebinding
protection (a localhost-only Host allowlist) at CONSTRUCTION, and this
module builds its FastMCP at import time with the default 127.0.0.1 host;
flipping settings.host in _serve afterward never updated the frozen
allowlist, so the LAN Host was always rejected.

When UNDERSTONE_HOST is off localhost, drop the allowlist in _serve before
run() — matching the SDK's own default for a non-localhost bind. The /mcp
and /watch routes are unauthenticated by design, so serve only on a trusted
network (documented).

Regression test pins the mechanism: a default FastMCP 421s a foreign Host,
a protection-disabled one accepts it. Tests 420 -> 421.
2026-06-13 04:40:40 -07:00
Patrick Buckley efa8664e4d ci(examples): name the Understone job distinctly
The job was named "test", colliding with core CI's "test" matrix so the PR
checks list showed two "test (3.11)" rows. Rename it to "understone" so the
example's checks read unambiguously (understone (3.11) / (3.13)).
2026-06-13 04:40:40 -07:00
Patrick Buckley a0a097dfa8 fix(examples): address PR review feedback (CodeQL + Copilot)
- CodeQL (implicit string concatenation in a list): collapse the wrapped
  bullets in cli._render_validate_coverage to single literals. The rendered
  output is byte-identical (the example's ruff ignores E501); clears all
  six alerts and reads cleaner.
- Copilot: packs/README no longer claims the directory ships "effectively
  empty" — it ships the bundled Cinder Wastes alternate world.
- Copilot: the Cinder Wastes' ash_flats and caldera_deep zones overlapped
  on column x=60 (inclusive bounds + first-match zone_for silently shadowed
  the tier-3..5 band onto a 1x5 deep-edge strip). Move caldera_deep to
  x0=61 — no overlap, no dead tiles, deep zone still covers the dungeon.
  And harden the loader: overlapping zone rectangles are now a
  WorldLoadError, so no authored pack can ship that bug unseen (the
  cold-author dogfood loop — a generated pack exposed a validator gap).

Tests 419 -> 420 (zone-overlap rejection). Both worlds validate sound and
remain winnable by the sim bot.
2026-06-13 04:40:40 -07:00
Patrick Buckley 30c09aaf51 ci(examples): run the Understone example test suite
The door-game example is a standalone package (no turnstone-core
dependency) that the root suite does not collect — its
testpaths are scoped to ["tests"], so the example's 419 tests, ruff,
and mypy gates never ran in CI.

Add a path-filtered workflow that installs the example and runs its
full gate (pytest + ruff check + ruff format --check + mypy) whenever
examples/door-game (or this workflow) changes, across the example's
declared Python floor and ceiling (3.11, 3.13). Pinned action SHAs and
contents:read permissions match the existing CI workflows.
2026-06-13 04:40:40 -07:00
Patrick Buckley 393a6fc2b2 feat(examples): Understone v0.10 — the satchel, the ore-forge, and the vault
A game-loop mechanics patch: the satchel becomes a real stacking inventory,
forging now demands ore won in combat (not just gold), and a vault lets a
hero protect coin from ambush.

- Stacking satchel: the bag re-encodes from a flat id list to "id:qty"
  stacks, so potions stack (three Minor Potions fill one slot, not three)
  and materials ride alongside. satchel_max now caps distinct KINDS (3);
  per-kind quantity is unbounded. quaff/death-save still pull the strongest
  potion and ignore materials. One pure codec (engine/satchel.py) owns the
  encoding; the façade, the Watch, and the sim all decode through it — no
  three-way drift (the v0.9 single-source lesson). The codec parses a bare
  id as qty 1, so it can never silently drop a malformed stack.
- Ore-gated forge: ore is a material that drops from won dungeon-rung
  fights (and, less often, forest fights), stacks in the satchel, and is
  not buyable or sellable — you earn your edge by fighting for it. Forging
  now costs gold AND ore ((plus+1) ore per tier), so a rich-but-idle hero
  can no longer buy power at the dice table. The dungeon is now also the
  mine.
- The vault: deposit/withdraw at the inn moves coin to a strongbox that
  ambush cannot touch and that SURVIVES the Wyrm-win legacy reset — the
  carry-vs-protect decision the PvP economy was missing.
- Surfaced on both the /watch lobby TV and the in-chat door_status sheet:
  each hero's stacked satchel, carried gold, and vaulted gold.
- Tuning (the sim is the instrument): the ore gate added ~2 days to the
  Vale and ~1.6 to the Cinder Wastes; the greedy bot still slays the Wyrm
  3/3 on both, fully forged to +3/+3, so the loop is not stalled. Defaults
  held — no numbers needed retuning.

Four new banded settings (forge_ore_item, forge_ore_per_plus,
ore_dungeon_drop, ore_forest_chance); both worlds gained an ore item.
Schema mutated in place (banked column, satchel re-encoding) — pre-1.0, no
migration by design; a real migration story is owed at 1.0. Tests 382 ->
419; the vault-survives-rebirth invariant and the codec are revert-verified.
2026-06-13 04:40:40 -07:00
Patrick Buckley 917e391b1f feat(examples): Understone v0.9 — colour roles for every object type
Graphics polish: distinct terrain and structures now read by COLOUR on the
Watch, not only by glyph. One unified palette, shared by every world — the
fix is to grow the set of distinct object-type roles, not to fork per-world.

- Roads were the tell: road shared the "floor" green with grass, so a path
  vanished into the meadow on the lobby TV. Likewise forest shared "tree",
  the three town buildings all shared "town", and the Cinder Wastes' molten
  slag borrowed "water" and rendered BLUE. Each is now its own role: road
  (stone), forest (lush green) with scrub (its barren ember-brown
  counterpart for volcanic/desert dense terrain that must NOT read as
  woods), lava (molten orange), barren (wasteland taupe), and inn/shop/
  healer split out of the generic town.
- Both worlds remap onto the shared vocabulary; in each, no two distinct
  terrain/building types share a colour. A live render caught the Cinder
  cinder-fields rendering green under the generic "forest" role — hence the
  scrub role, so the volcanic waste reads warm. The text frame renderer
  stays monochrome (it never read colour), so frames and goldens are
  untouched — this is Watch-only.
- The bug class is now closed by construction: a test asserts the Watch
  PALETTE carries a hex for EVERY Color role, so a role can never ship
  unpaintable and silently fall back (which is exactly how road hid).
- Color.assignable() is the single source for the overlay-vs-assignable
  split (runtime actor/item colours and the DEFAULT fallback are not
  author-pickable); the authoring manual's colour vocabulary generates
  from it, so it can't drift.

Tests 373 -> 382. floor/tree/forest are three greens kept deliberately
distinct (forest is olive-hued); verified on a real render along with the
scrub fix.
2026-06-13 04:40:40 -07:00
Patrick Buckley 65e7b404bc feat(examples): Understone v0.8 — worlds without authors
The slice that proves the pipeline: a second world authored entirely by an
LLM from AUTHORING.md and the validator alone, plus the tooling to discover,
theme, and balance-test any world.

- The dogfood: "The Cinder Wastes" — an ashen volcanic underworld (slag
  rivers, a caldera mouth, a Magma Wyrm) — was written cold by an agent
  given only the generated authoring manual and `understone validate`. It
  passed validation on the FIRST run with zero failures. Its stumble log
  found six places where the manual stated a rule the validator didn't
  enforce; those became permanent hardening (below). It ships in
  understone/world/packs/ and glows ember on the lobby TV.
- `understone worlds` lists every bundled world (the Vale + alternates)
  with its load status, via one shared discovery path.
- Per-world Watch themes: settings.watch_theme (phosphor/amber/ice/ember,
  loader-validated) repaints the spectator page; the Vale's green is
  byte-for-byte unchanged.
- The sim harness: a pure, seeded, greedy bot plays the real game façade
  over an injected day-stepping clock and emits a balance report —
  `understone simulate PATH [--days N] [--seeds K]`. It SLAYS THE WYRM on
  both worlds (Vale ~day 13, Cinder ~day 25), so the whole v0.1->v0.7 loop
  is proven winnable end-to-end by an unclever bot through the real stack.
- Loader hardening from the dogfood: a rare monster may not occupy a
  dungeon-rung guardian slot (it would silently become a fixed foe and
  leave the rare pool); exactly one monster may be the boss; and the
  boss-tier error now says "no non-boss monster," matching the manual.
  AUTHORING gained a generated "what validate checks vs. what it cannot"
  section so the rule/guidance boundary is honest.

Review hardened the bot for arbitrary authored packs (a MENU-mode fight
spin and four related robustness gaps that were latent on the shipped
worlds), and documented that final_level reads post-legacy-reset. Tests
359 -> 373; both worlds still win byte-identically after the fixes.
2026-06-13 04:40:40 -07:00
Patrick Buckley dcc0e5fb0a feat(examples): Understone v0.7 — the deep, the satchel, the forge, rare beasts
The depth slice: four standing reasons to return past the daily reset.

- The rung ladder: the dungeon is a descent fought one rung per turn, each
  guardian a fixed tier. A loss bounces you home but your depth PERSISTS —
  you re-enter where you left off. The Wyrm now gates on BOTH level AND
  reaching the floor (the deep has a bottom, and you must have touched it).
- The satchel + the death-save: potions are CARRIED now (up to three),
  bought to the satchel, drunk with quaff. The heart of it: when any fight
  would kill the active fighter and they carry a draught, the strongest is
  drunk automatically — they survive standing at the potion's value, no
  bounce. This fires on EVERY fight (forest, rung, and the Wyrm itself —
  a potion carried to the climax is a real tactical choice); a Wyrm loss
  so saved is "driven back, alive but unproven," not devoured. The sleeping
  ambush victim never quaffs (they are asleep). combat.py stays pure — the
  satchel and the save live entirely in the façade.
- The forge: the shop spends scaling gold to add a +1 edge to equipped
  weapon or armour, capped — the late-game gold sink. Swapping or selling
  the piece loses the edge with it (one centralized unequip clears the
  bonus and the plus so a stat can never go phantom).
- Rare beasts: a few named foes prowl the forest via weighted selection,
  surfacing seldom; felling one is a public Herald flash and always yields
  a draught into the satchel. Rung guardians are never rare (fixed foes).

Four new player columns; four new banded settings; dungeon_tiers extended
to three rungs. Tests 283 -> 330; the death-save (all four paths), forge
accounting across forge/buy/sell/legacy, rung math, and weighted rare
selection all pinned, with the death-save and forge invariants
revert-verified.
2026-06-13 04:40:40 -07:00
Patrick Buckley b76b2a98d0 feat(examples): Understone v0.6 — UTF-8 graphics and the width discipline
The look of the next age — the modern equivalent of the ASCII->CP437 leap.
Full Unicode is available now, but the whole stack (text frames, golden
tests, the Watch's 1ch grid) assumes one glyph = one column, so the
enabling piece is a WIDTH RULE, not the glyphs themselves.

- textwidth.is_grid_safe: one code point, printable, East-Asian width not
  Wide/Fullwidth, no combining/format/control category. This is the
  one-glyph-one-column contract. Ambiguous-width glyphs are ACCEPTED on
  purpose — they ARE CP437 (the wall, the club-tree, the up-arrow forest)
  and render single-column on the Western-monospace metrics every surface
  uses; only genuinely double-width runes are barred. The loader enforces
  it on every map glyph; the player-name/free-text sanitizer enforces the
  same rule (the narrow ledger), so a wide name can't shear a frame.
- Re-skin: water ~ -> ≋, inn -> ⌂, healer -> ✚, dungeon mouth -> ∩, and
  the other adventurer -> ☻ (CP437's own player glyph). The colour field
  the renderer has carried unused since v0.1 now has a second consumer.
- Texture variants: grass and water vary by a deterministic per-coordinate
  hash, rendered identically in the Python frame builder and the Watch's
  JS. The two are kept in lockstep by shared hash constants + an agreement
  test that replays the JS arithmetic and asserts it equals the Python
  output for every variant over a grid — not a comment-coupled copy.
- Watch glow-up: a Noto Sans Mono font stack and a UTC-hour day/night tint
  (the Vale darkens at dusk on the lobby TV).
- The curated SAFE_PALETTE is enforced author-usable: a test asserts no
  palette glyph collides with the reserved player markers, so AUTHORING's
  generated appendix can't advertise a glyph the loader would reject.
- Resume is identity-preserving: an existing character resumes by exact
  stored name without re-validating the width rule (which governs creation
  only) — resume must never lock anyone out.

Tests 231 -> 283; width edges (CJK/emoji/combining/fullwidth), the
Python<->JS lockstep, the palette/reserved guard, and resume-vs-create all
pinned and revert-verified.
2026-06-13 04:40:40 -07:00
Patrick Buckley 08d46f086f feat(examples): Understone v0.5 — ambushes, the inn mailbox, and dice
The social slice: the shared world gets teeth, letters, and a house game.

- Ambush (async PvP, classic door-game player-kill spirit): waylay an adventurer who has
  not yet begun their day. Ordered gates — known target, not yourself, the
  gatekeeper shields the young (both >= min level), level band +-2, the
  SLEEP RULE (acting today makes you watchful — an active-play defense),
  mercy for the downed (hp<=1 cannot be piled on: even bandits have
  standards), once per pair per UTC day. Win: capped gold cut transfers,
  victim wakes at the spawn-stone with a private note; lose: the sleeper
  wakes blade-in-hand and the Herald crows your shame. The attacker wears
  the counter-blows the combat log narrates (state matches story). Both
  players persist in one transaction.
- The inn mailbox: events carry a target ('' = public). door_log delivers
  private notes to the addressee only; the Watch and other players never
  see them. Mail is DURABLE past the in-memory tail (SQLite backfill for
  cursors older than the resident window) — the broadsheet is ephemeral,
  letters are not. Sanitized, daily-capped.
- Inn dice: 2d6 against the house, bet- and count-capped per day, big wins
  make the news.
- Six new banded settings; four day-counter columns join the shared lazy
  UTC reset; schema stamp stays 1 (pre-1.0 mutates in place by design).

Tests 184 -> 231; sleep rule, mercy gate, band boundary (exact/over),
refusal precedence, attacker wear, zero-gold robbery, mail eviction
survival, and Watch privacy all pinned; guards revert-verified.
2026-06-13 04:40:40 -07:00
Patrick Buckley d40c4c85ee feat(examples): Understone v0.4 — the authoring pipeline (worlds as data)
The IGM seam realized: world packs are now a first-class authoring target
for models and humans, with a validate loop and a loader hardened for
routinely-untrusted generated content.

- understone newpack DIR scaffolds a pack (the six content JSONs templated
  from the shipped Vale) plus AUTHORING.md — a manual written for a model
  to follow cold. Its bands table is RENDERED FROM the loader's own band
  constants at scaffold time, so documented limits and enforced limits
  cannot drift.
- understone validate DIR loads a pack and prints either a pack report
  ("This pack is sound. The door stands open.") or the loader's
  file/index/field-naming error — the authoring feedback loop.
- Loader hardening: glyphs must be one printable column-safe character and
  never the frame box-drawing set or the @/& player markers (map content
  cannot impersonate players or forge frame chrome); map dims 8..256;
  per-file count caps; display-name length caps. All errors instructive.
- The packaged-world path is single-sourced (understone.world.
  PACKAGED_WORLD_DIR) for the server default and the scaffold template.
- README "Authoring worlds" section frames the loop: newpack -> write or
  generate -> validate -> serve with UNDERSTONE_WORLD=dir.

Review round: bug finder returned zero findings; quality round fixed the
world.json doc example (it showed a zone fragment where an authoring model
would copy a whole-file shape — now a labeled skeleton), the stale Usage
docstring, and the duplicated packaged-path constant.

Tests 166 -> 184. Scaffold round-trips through load_world by test.
2026-06-13 04:40:40 -07:00
Patrick Buckley d54110ffcb feat(examples): Understone v0.3 — the Watch (lobby TV) + a livelier Vale
A read-only CRT spectator page served by the game process itself, plus
content depth. Input never flows through the Watch — it is the wall-mounted
terminal in the BBS room; chat remains the only actuator, so there is no
input channel to deadlock and no cross-origin surface (the page polls the
same origin that served it).

- /watch: one self-contained page (inline CSS/JS, no external assets),
  phosphor CRT styling. The base map paints once from /watch/world.json
  (terrain glyph rows + a glyph->color legend — the palette the text
  renderer has deliberately ignored since v0.1 finally gets its first
  renderer); players overlay as positioned glyphs repainted from
  /watch/state.json every 2s; the sidebar carries the roster with win
  stars, the Hall of Legends, and the Herald. SIGNAL LOST on poll failure;
  the bootstrap retries so a spectator arriving during a server blip
  recovers without a reload.
- Routes ride FastMCP custom_route on the existing process — read-only
  handlers with no awaits between reads (handlers and sync tools
  interleave on one event loop, so every response is a consistent
  snapshot).
- door_join/door_help advertise the Watch URL in http mode (stdio: none).
- Content: +5 monsters (one per tier; the gauntlet's first-in-tier foes
  preserved), +3 items smoothing the gear curve, +6 events; fight weight
  retuned to hold ~55% of encounter rolls. Zero geography churn.
- Review round: the Herald window is a plain list tail (id arithmetic
  under-reported the feed when AUTOINCREMENT ids gap — regression-pinned
  with sparse ids), and the bootstrap-retry fix above.

Tests 149 -> 166.
2026-06-13 04:40:40 -07:00
Patrick Buckley 4b8681db8a feat(examples): Understone v0.2 — the Wyrm, forest events, and the Herald
The "make it a game" slice: a win condition with classic-door-game-style legacy, texture
between fights, and a shared broadsheet.

- The Wyrm Below: a boss (flagged in the pack, excluded from random bands)
  behind a level-gated `challenge` verb at the dungeon. Victory writes a
  Hall of Legends row and the character resets to the fresh-start kit,
  keeping a wins counter rendered as ★ on the leaderboard — the classic
  race-reset-race loop. Defeat and stalemate flight make the news.
- Forest events: movement encounters weighted-pick from a content-pack
  table (fight/gold/heal/trap/lore). Only fights stop the walk or cost
  turns; texture is free and private. Trap damage floors at 1 hp.
- The Understone Herald: door_log is a broadsheet with a masthead and
  write-time template variety; the public feed is curated to notable beats
  (joins, blessings, level-ups, defeats, the Wyrm's fate) — town errands
  stay private.
- Reward narration moved from the combat engine to the façade, composed at
  the moment gold/xp are actually banked, so the server can never narrate
  a reward it did not apply (the Wyrm win previously claimed +400 XP /
  +250 gold that the legacy reset wiped).
- Fresh-start hp/atk/def promoted into world.json settings alongside the
  starting kit; dungeon-tier validation counts non-boss monsters only,
  keeping the validator's no-silent-rung promise true.

Schema mutated in place (players.wins, hall_of_fame) — pre-release, no
migration path by design. Tests 109 -> 149; the challenge level gate is
negative-tested; rank stars survive 24-char names (compact form past 5).
2026-06-13 04:40:40 -07:00
Patrick Buckley 99e7dc17ec feat(examples): Understone — a BBS door game as a standalone MCP server
A shared-world, classic-door-game-style door game in examples/door-game/: a pure-stdlib
game engine (tile overworld + location menus, seeded combat, daily turn
budget, leveling, shop, event log, leaderboard) behind nine sync door_*
FastMCP tools returning monochrome box-drawing frames. The connecting
session's LLM plays dungeon master — tool descriptions plus a door_help
manual teach a cold model to run the game with zero setup, while the server
owns all dice and state, so the DM narrates around facts it cannot bend.

Non-obvious decisions:
- engine/screen/world/persistence import stdlib only; server.py is the only
  mcp import. All nine handlers are sync def: on mcp 1.27 they execute
  inline on the event loop (verified against func_metadata), so tool bodies
  serialize and one SQLite connection (WAL, per-action commit) is safe.
  check_same_thread=False exists only because the Store may be constructed
  on a different thread than the serving loop.
- Streamable HTTP serves ONE process = one shared world (players appear on
  each other's maps; async "while you were away" event feed); stdio is the
  solo-world fallback.
- The economy is content, not code: daily_turns, costs, xp curve, bestow
  budget, and dungeon tiers live in world.json settings, band-validated by
  the loader. door_bestow gives the DM capped, event-audited largesse
  (gold/heal only, never turns) so story generosity cannot melt the shared
  leaderboard.
- Player names and bestow reasons are sanitized (printable-only, length
  caps) because they flow into the shared event log and from there into
  other players' DM context — embedded newlines would forge log lines.
- Daily turn/bestow pools lazy-reset per UTC day on every consuming path
  (injectable clock); the dungeon gauntlet is a fixed boss ladder by design.

Tests: 109 — engine units with seeded RNG + frozen clock, hand-authored
golden frames paired with structural asserts, loader band rejections, and
one real-wire integration test (uvicorn + streamablehttp_client) with a
two-session shared-world assertion. Negative-tested by reverting the guard
and watching the suite fail: the daily turn-budget guard, the bestow cap,
and the sanitizer's isprintable clause.
2026-06-13 04:40:40 -07:00
Patrick Buckley 30b590fb25 feat(memory): durable per-user coordinator scope + anonymous-coordinator guard
The coordinator memory scope was keyed by the session's ws_id, so every
new coordinator session started with an empty namespace and its rows
were orphaned on close — coordinator memory never actually persisted.
Re-key the scope to the coordinator's creator user_id: one durable
orchestration namespace per user, shared by all of that user's
coordinator sessions (concurrent ones included; upsert-by-name is the
collision rule).

The child-containment threat model is unchanged: the gate is session
KIND — children are always interactive and share the parent's user_id,
so _validate_scope rejects them before scope resolution, and the REST
memories API still rejects the coordinator scope outright. The implicit
visibility lane now also fails closed on an empty scope_id to match the
explicit search/list lanes (the storage helpers treat a falsy scope_id
as 'no scope_id filter', which would have read every user's rows).

Anonymous coordinators are no longer constructible: ChatSession refuses
kind=COORDINATOR with an empty user_id at the constructor — the single
choke point covering create, rehydration of legacy rows (surfaced by
the open handler as a 503 with remediation text), and any future host —
and the console no longer masks an empty uid as a phantom 'system'
principal when minting coordinator JWTs, per CoordinatorTokenManager's
documented 'sub = the real creator user_id' contract.

Migration 061 carries existing coordinator rows across: rows whose
owning workstream is gone or ownerless are deleted (unreachable under
user keying), same-name collisions within a user keep the newest
updated row (memory_id tiebreak), and survivors re-key to the owner's
user_id.
2026-06-12 13:54:49 -07:00
Patrick Buckley ce105c4ed1 fix(ui): split separator ARIA range reflects the real clamp, not 10–90
_buildHandle hard-coded aria-valuemin/max at 10/90 (inherited from the
old ui/static implementation) while the actual drag/keyboard clamp is
_ratioBounds — the cell minimums against the split node's OWN px region
(a 1200px host really clamps at ~17/83; nested splits sit tighter), so
assistive tech was told a wider range than the separator allows.

aria-valuenow/min/max are now all written in _applyLayout's handle loop
from _ratioBounds(h.node) — one writer, refreshed on every drag,
keyboard nudge, and structural change. A bare window resize can stale
the advertised range until the next interaction (no resize listener by
design — % insets make resizes free), still strictly truer than a
constant. The max>=min guard covers a host shrunk below two cell
minimums, where the bounds legitimately cross.
2026-06-12 00:11:08 -07:00
Patrick Buckley d8619ce3c8 docs(ui): the pane-hosted coordinator scope is every coordinator in practice
The /coordinator/{ws_id} standalone page is reachable only by direct
URL — all three console navigation sites are shell-fallback else
branches behind openPane. Record that in the sidebar-padding comment
so the scope isn't over-read as a live second surface.
2026-06-12 00:11:08 -07:00
Patrick Buckley 482e6648ca fix(ui): drop the pane-hosted coordinator sidebar below the corner chip
The per-pane ✕/− chip floats at the pane's top-right — exactly where
the coordinator sidebar's toggle row and Children refresh button sit,
so the chip covered them. Pane-hosted coordinators now start the
sidebar content 44px down (padding, not margin, so the column's left
border still runs the full pane height); the standalone coordinator
page has no chip and keeps the 14px default.
2026-06-12 00:11:08 -07:00
Patrick Buckley ed08986d93 fix(ui): split-view pre-push review round — mode-distinct chip, anchoring, light-theme AA
Dual designer review (one primed on the branch context, one cold), all
measured findings applied:

- The per-pane chip was a mode-error trap: identical glyph at the
  identical locus, reversible in split mode (hide cell) but destructive
  single-pane (close pane). Now − hides, ✕ closes, and the close mode
  wears a danger hover/focus ring so the irreversible action telegraphs
  before the click lands.

- Single-pane chip anchored to the VIEWPORT: an unpositioned section
  resolves absolutes to <body>, so the chip only coincidentally landed
  near the pane corner. .panes > section.pane is now position:relative
  in both modes (all pane-content absolutes verified to anchor to their
  own local relative parents).

- Light-theme AA (measured): .shown tab underline 55% mix composited to
  2.34:1 -> 80% (~3.7:1 light / ~5:1 dark); focused-cell ring 2.60:1 on
  light -> 75% mix override there (dark keeps 55% at 3.75:1).

- Chip: border --hair-2 measured ~1.3:1 (invisible) -> --ink-4; 22px
  target under WCAG 2.5.8's 24px floor -> 28px; right offset clears the
  message scrollbar gutter; light resting glyph one ink step up.

- Focus bar inset 1px from cell sides (no doubled-accent stripe where
  it butted a separator at the T-junction); greyscale font smoothing on
  the tail glyphs (subpixel RGB fringed the box-drawing characters).

Rejected with rationale: aria-pressed on the split buttons (they are
one-shot verbs — splitting again nests — not mode toggles).
2026-06-12 00:11:08 -07:00
Patrick Buckley 44c11efb53 feat(ui): split-view follow-ups — per-pane ✕, child-opens-beside, close-on-ws_closed
Four refinements from first live use:

- Per-pane ✕ chip, top-right of every visible pane. Split mode: hide
  that cell (closeCell — the tab stays, the sibling absorbs the space).
  Single-pane: close the pane outright (withheld from the unclosable
  Dashboard). The click decides at click time; the label tracks the
  mode. Manager-injected into the pane section — content untouched.

- Coordinator child links open BESIDE the coordinator (openPaneBeside:
  split right of the focused cell, seeded with the child pane) instead
  of replacing it — the parent stays on screen. Degrades to the plain
  focused-cell swap when the split is denied (cap / narrow viewport).
  splitFocused() gained an optional explicit-fill parameter for this.

- Tier-1 ws_closed now CLOSES the open interactive pane (tab gone, a
  split cell collapses) — the coordinator-closes-its-child flow,
  matching the standalone's pane-auto-close. The dead-banner lane
  stays for streams that die without a ws_closed (node crash/network),
  where the session may still be revivable.

- Paint bug: the focused-cell ring was an inset box-shadow on the
  section, which paints in the element's own background layer — UNDER
  opaque children touching the edges, so the status bar / composer
  strip occluded it. The ring now rides a click-transparent ::after
  overlay above pane content; the 2px top bar sits above the ring line.

The livepass shell surface's demo panes grew a .ws-status-bar footer so
the occlusion bug class stays visible to future passes.
2026-06-12 00:11:08 -07:00
Patrick Buckley f8f7152d63 feat(ui): split view returns to the L-shell — PaneManager layout tree
Revives the split-pane feature retired with ui/static (step 6), rebuilt
on PaneManager: an optional binary layout tree (null = the one-pane-per-
tab behaviour, unchanged) renders visible panes as %-inset cells — no
reparenting, so live stream DOM, scroll state and media survive layout
changes. Tabs stay global: the active tab is the focused cell, a
backgrounded tab swaps into it, clicking inside a visible pane focuses
its cell, .shown marks visible-unfocused tabs. Separators resize by
pointer-capture drag and arrow keys (role=separator + aria-value*); the
tree persists in the working-set blob and rehydrate prunes leaves whose
pane did not restore. Limits: 6 cells, 200x150 cell minimums, denials
toast the manager's reason.

Affordance: Split right / Split down / Unsplit buttons in the tab-bar
tail replace the redundant [+] (the permanent Dashboard tab is the
launcher) — deliberately no contextmenu override this time. The dead
TS_APP.focusLauncher seam goes with it.

Measured chrome: the focused cell wears a 2px accent top bar (no thin
tinted ring clears 3:1 in both themes) plus a 55%-mix inset ring;
separators rest at --ink-4 with solid-accent hover/drag/focus; .shown
tabs carry an accent underline; the tail cluster is fenced and lifted
to --ink-3.

scripts/livepass.py grows a third surface: shell/livepass.html boots
the real shell.js + pane.js and drives ?split=right|down|three|none
(+ &theme=light), stamping SPLIT-READY-<cells> / SPLIT-FAILED-<reason>.
2026-06-12 00:11:08 -07:00
Patrick Buckley 3e5f2c3870 test: zero out the suite's warning noise
121 warnings -> 0. Two upstream deprecations get narrowly-scoped
filterwarnings entries (the mcp streamablehttp_client rename — adoption
deliberately rides the v2 migration since the new entry point's call
shape changes again there; the starlette httpx TestClient notice). The
one real RuntimeWarning is fixed at the source: tests that mock
asyncio.run_coroutine_threadsafe handed real coroutines to a stub that
never awaited them, GC-firing 'coroutine was never awaited' inside
whatever unrelated test ran later (the same cross-test bleed mechanism
as the CI closed-stream spew — per-test filterwarnings markers cannot
catch it, which is why two such markers existed and still leaked). A
shared _dispatch_stub now closes real coroutines before returning the
canned future; the obsolete markers are removed.
2026-06-11 20:42:34 -07:00
Patrick Buckley 1497c392e4 chore: cap mcp <2 ahead of the v2 breaking rewrite
mcp 2.0.0a1 shipped 2026-06-11 (stable targeted ~2026-07-27). v2 removes
streamablehttp_client, changes the transport tuple arity, and renames
mcp.types fields to snake_case — all of which our client imports. The
maintainers' release note asks downstream packages to add an upper
bound now (their worked example is this exact constraint). Floor stays
at 1.27: nothing newer adds anything our surface needs, and the #2147
shutdown busy-loop we wrap remains unfixed at every released version.
Resolution is unchanged (1.27.2); lockfile re-pinned metadata only.
2026-06-11 20:42:34 -07:00
renovate[bot] af3cfc509d chore(deps): update docker images to v0.11.21 2026-06-11 20:42:10 -07:00
Patrick Buckley 7ef04e576a fix(providers): require base_url for anthropic-compatible
Copilot review on #661: empty base_url let the SDK fall back to
https://api.anthropic.com, sending compat-shaped requests to the
commercial API. The lane is local-only by definition, and the /v1-strip
edge case already established fail-loudly-over-silent-prod-retarget;
apply the same principle to the empty case. create_client raises an
actionable ValueError; the admin Detect path surfaces it as a clean
error string via probe_model_endpoint's existing handler.
2026-06-11 20:27:29 -07:00
Patrick Buckley 12bd848c68 feat(providers): anthropic-compatible lane for local /v1/messages servers
Add provider id "anthropic-compatible": the existing AnthropicProvider
pointed at Anthropic-compatible local servers (vLLM /v1/messages),
mirroring the openai/openai-compatible split. Registry-only — configured
via the admin Models tab or [models.*] toml, not exposed on the bare
--provider flag, so the CLI/server prod-URL defaults are unreachable for
the lane and real-Anthropic behavior is untouched.

Lane behavior (live-verified against vLLM 0.22.1rc1 + DeepSeek-V4-Flash):
- Capability defaults replace the Claude static table: token_param
  max_tokens, thinking_mode none, web_search/tool_search/vision off,
  reasoning replay on. vLLM rejects Anthropic server-side tool types
  (tools require input_schema) and ignores the thinking request param,
  so neither is sent; thinking blocks still stream back and round-trip
  through the native lane verbatim.
- Reasoning toggles via server_compat extra_body chat_template_kwargs
  (first-class vLLM request field; request-level keys beat server
  defaults). _build_thinking_and_kwargs forwards non-internal
  extra_params as SDK extra_body; thinking_budget_tokens stays internal.
- No temperature force: thinking_mode none skips the Claude-only
  temperature=1.0 requirement.

Admin UI: provider option + URL placeholder (base_url without /v1 — the
SDK appends /v1/messages); the server-compat section shows only the
extra-body field for the lane. thinking_mode round-trips through the
form dropdown for every provider except anthropic-compatible, where it
stays in the raw capabilities JSON — the edit-load lift and save restore
use the same predicate so stored overrides are never silently dropped.

Docs: architecture.md gains the lane subsection incl. verified quirks
(thinking param dropped by vLLM; stop_sequences cut inside thinking and
report end_turn; usage has no cache fields; images need a multimodal
model; mid-conversation system turns are per-model opt-in).

Negative-tested: removing the _INTERNAL_EXTRA_PARAMS exclusion fails
test_internal_keys_not_leaked; the live test drives a streamed turn with
the chat_template_kwargs toggle and asserts no reasoning deltas.
2026-06-11 20:27:29 -07:00
Patrick Buckley 3f5ee333fb fix(mcp): close the shutdown drain race + close the owned loop
Review feedback: (1) gating the drain on a main-thread truthiness check
of _background_tasks could skip cancellation when a spawn queued via
call_soon_threadsafe had not reached the set yet — submit whenever the
loop is RUNNING and snapshot on the loop, where FIFO callback order
guarantees earlier-queued spawns have landed; (2) shutdown stopped the
loop thread but never closed the loop or cleared _loop/_thread, leaking
selector resources for embedders that cycle managers — close + clear
when we own the thread and it actually stopped (loud warning when it
does not); unowned loops (tests wiring _loop directly) stay untouched;
(3) the bare await-in-suppress drain loops become
asyncio.gather(return_exceptions=True) in both the shutdown drain and
the test fixture.
2026-06-11 18:15:58 -07:00
Patrick Buckley 6c48af1900 fix(mcp): track fire-and-forget background tasks; harden loop teardown
The post-reconnect catalog refresh was scheduled as a bare
asyncio.create_task: no strong reference (the task could be GC'd
mid-flight, so the refresh might silently never run) and no exception
retrieval (failures surfaced as "Task exception was never retrieved"
at GC time — in CI, onto an already-closed pytest capture stream, the
"I/O operation on closed file" spew; a suspected contributor to the
flaky 60-minute CI hangs via cross-test loop/task state bleed).

- _spawn_background(coro, label): tracked-task set + done-callback
  that retrieves and logs failures at warning; discard runs LAST so
  set-emptiness means "done AND reported"
- shutdown() drains tracked tasks FIRST, so stack teardown can't race
  an in-flight refresh; same run_coroutine_threadsafe idiom and
  timeouts as the existing close steps
- running_loop_mgr fixture: cancel-pending -> drain -> stop ->
  join(5) with a loud assert -> loop.close() (was stop + silent
  join(2), never closed)
- the false-property test ("swallows refresh failure" — nothing
  swallowed it) now waits for completion and asserts the logged
  warning via the patched module logger (structlog; caplog cannot
  observe it), polling inside the patch context
2026-06-11 18:15:58 -07:00
Patrick Buckley 5ff726dd7a fix(storage): enforce orphan-ness inside the purge DELETE + chunk IN-lists
Review feedback on the purge's race window: the pre-SELECT re-verify
left a statement-to-statement gap where a concurrent registration could
still lose rows — and the pre-counted refcount release could underflow
when it didn't. Orphan-ness now rides the DELETE itself (correlated
NOT EXISTS) with refcounts released from its RETURNING, so refs are
released for exactly the rows that were deleted. Input is de-duplicated,
IN-lists chunk at the storage layer's 500 convention, and the scan's
per-workstream ref-count loop is now one anti-join pass.
2026-06-11 14:00:42 -07:00
Patrick Buckley 06bb375916 feat(admin): orphan-conversations maintenance verb — scan + purge
Conversation rows whose workstreams row is gone (historical unregistered
writers; the delete-during-inflight race re-creating rows after
delete_workstream) are invisible cruft that also pins attachment
refcounts. Add a turnstone-admin verb: default = read-only scan report
(ws_id, rows, attachment refs, first/last); --delete [--yes] purges.

- shared find/purge logic in storage/_utils; protocol + both backends
  in lockstep (thin wrappers)
- purge re-verifies orphan-ness in-transaction: a ws_id re-registered
  between scan and purge is skipped, never deleted
- releases the deleted rows' attachment refcounts through the
  delete_workstream GC path and sweeps workstream_config/overrides
- summary reports actual purge results, including the skipped clause
2026-06-11 14:00:42 -07:00
Patrick Buckley ef7fdb3a26 fix(ui): re-home MCP consent badge on the Manage Connections row (#657)
* fix(ui): re-home MCP consent badge on the Manage Connections row

The L-shell renovation retired the standalone settings gear (#settings-btn).
The MCP pending-consent badge anchored to that gear via _refreshConsentBadge,
which null-guarded silently — so since the renovation pending consent requests
had no indicator (the badge was invisible).

Re-home the badge on the rail's Manage row where the MCP/connections surface
lives in both deployments:

- rail.js gains a generic setRowBadge(tabKey, count, label?) hook + a `badge`
  builder: a small ⚠-glyph + count chip (never colour alone) using the DS warn
  tokens. mountManage registers row + owning-group-head refs and re-applies live
  counts across a (re)mount. When the owning group is collapsed, the count also
  mirrors onto the group head so a hidden row never hides the signal. rail.js
  stays agnostic — it owns the mechanism, the caller owns the meaning.
- shell.js (the ESM bridge) re-exports setRowBadge on window.TS_SHELL so the
  classic ui/static/app.js subsystem can drive it without importing the module.
- The standalone consent subsystem keeps its shell-level ownership: _refresh-
  ConsentBadge now drives setRowBadge on the Connections tab, fed by both the
  loadPendingConsents hydrate/poll load and live onConsentDetected notifications.
- The shared interactive pane host bridges onConsentDetected to the new
  window.TS_APP.onConsentDetected seam (undefined on the console, so the console
  pane stays a no-op there); panes only notify.
- The dead colour-only gear badge CSS (.settings-consent-badge, red dot) is
  removed; the new chip lives in shell.css as token-only .rail-badge so it
  flips themes by construction.

Console MCP tab (Extensions > mcp) and standalone Connections tab
(Extensions > connections) both badge correctly. Pins extended in
test_shell_js.py + test_app_js.py.

* fix(ui): drop the unused head ref from the rail badge row map

Review feedback: _rowEls stored each row's group-head element but every
head consumer resolves it through _groupEls; keeping the duplicate DOM
ref made the remount state shape harder to reason about.
2026-06-11 14:00:06 -07:00
Patrick Buckley d9f5093a17 test(console): make dedupe-pin slice bounds reformat-tolerant
Review feedback: the next-case end markers were exact-indentation
string finds that raised a bare ValueError when unmatched. Use
whitespace-tolerant regexes with actionable assertion messages, and
bound the history-replay window structurally (next role branch, with
a generous fallback) instead of a fixed 600 chars.
2026-06-11 13:49:35 -07:00
Patrick Buckley b11565a1f6 fix(ui): single-path Enter activation + hls.js teardown on player error
Review feedback: (1) the Enter keydown re-dispatched through btn.click(),
relying on the disabled-guard to suppress the browser's own
Enter-to-click — preventDefault + direct activation makes the keyboard
path provably single-fire; (2) the branch-scoped Hls instance was
unreachable from the media error handler, leaking its listeners and
loader timers when the player node was replaced with the retry UI —
hoist the ref and destroy it before replacement.
2026-06-11 13:48:28 -07:00
Patrick Buckley 8b41b32174 fix(ui): lift media player activation into the shared interactive pane
The interactive Pane renders media embeds (buildMediaEmbed / buildPlayButton),
but the Play activation — _loadHls / _isHlsUrl / _activatePlayer and the
click/keydown delegate — stayed behind in the standalone ui/static/app.js as
DOCUMENT-level listeners. The console L-shell mounts the same interactive.js
module but never loads ui/static/app.js, so the Play button was dead in
console-hosted interactive panes.

Lift the activation into shared_static/interactive.js (alongside the existing
buildMediaEmbed/buildPlayButton — media embeds are interactive-pane-only; the
coordinator pane renders none) and wire it as a pane-owned, root-scoped
this.el click/keydown listener, mirroring the approval-keydown pattern the
fork collapse established. The standalone copy is deleted so no duplicate
implementation remains; both deployments now activate through the one shared
handler.

The hls.js vendor is fetched lazily by absolute /shared/ URL (the same
mechanism renderer.js uses for mermaid), and /shared is mounted at the root in
both turnstone/server.py and turnstone/console/server.py, so the vendor —
which ships in shared_static/hls-1.6.16/ — resolves in both deployments with
no HTML change.

Pins: assert the lift + pane-ownership in test_interactive_pane_js.py and the
standalone-stays-clean guard in test_app_js.py.
2026-06-11 13:48:28 -07:00
Patrick Buckley c988c9ed1f test(console): pin system-turn dedupe wiring on both read paths
The live-SSE/history system-turn dedupe (renderedSystemEventIds /
_renderedSystemEventIds) was already in place on both panes and merged
to main (21af6c4 aligned the persisted row event_id with its SSE event;
09e41d1 added the belt-and-braces Set on the coordinator). The existing
pin tests only assert the Set's .has()/.add()/.clear() symbols appear
somewhere in the file, so a refactor that keeps the Set but short-circuits
the live-handler consultation (guard -> false) re-opens the double-render
while the pins stay green.

Scope the new assertions to their blocks: the live system_turn case must
CONSULT and RECORD against the Set, and the history render path
(replayHistory / refetchHistory's system-role branch) must record each
replayed row's event_id. Bounded at the next switch case rather than the
first break; the dedup-skip path itself breaks before the .add(), so a
break-bounded slice would drop the record half.

Verified the new slice checks fail on a dedupe-neutered factory (a
headless-Chrome harness driving the real createCoordinatorPane confirms
that neutering produces two rendered nodes for one event id; intact code
renders one, and the no-event-id legacy path still renders both).
2026-06-11 01:13:21 -07:00
Patrick Buckley ee799f67de fix(memory): touch access metadata on composition and tool reads
The touch_structured_memories facade and both storage backends were
implemented but had zero call sites, so access_count never moved and
last_accessed never advanced past write time on any deployment.

Wire two touch points:
- proactive composition touches the injected top-k (post-rerank) set,
  deduped per turn since _init_system_messages recomposes many times
  within a single turn;
- the memory tool's search and get reads touch their returned rows,
  counted per call. save/delete/list do not touch.

Touches are best-effort through the facade, which already swallows
storage errors, so a failed touch never breaks composition or a tool
call.
2026-06-11 01:12:28 -07:00
Patrick Buckley a137bffa25 chore: bump version to 1.7.0a1 2026-06-10 22:21:43 -07:00
48 changed files with 502 additions and 1893 deletions
+2 -9
View File
@@ -35,9 +35,6 @@ jobs:
test:
runs-on: ubuntu-latest
# Cap a hung run at 20 min instead of riding GitHub's 6-hour default
# (a flaky-hang run otherwise streams -v output for hours).
timeout-minutes: 20
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
@@ -54,10 +51,7 @@ jobs:
with:
node-version: "24"
- run: pip install -e ".[test]"
# -v lists each test id as it starts (pytest prints the nodeid at
# logstart), so a hang names the culprit on the last line instead of
# riding the job timeout with only a trail of "..." dots.
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -v
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
@@ -66,7 +60,6 @@ jobs:
test-postgres:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:18
@@ -90,7 +83,7 @@ jobs:
with:
node-version: "24"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -v
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
env:
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
+1 -3
View File
@@ -17,10 +17,8 @@ RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
# ripgrep is the preferred backend for the search tool — natively bounds
# per-line, per-file, and per-filesize so pathological inputs (minified
# bundles, training-data JSONL with multi-MB single records) can't OOM us.
# ffmpeg transcodes omni STT uploads (browser webm/opus) to the 16 kHz mono
# WAV the omni chat-audio lane decodes.
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 git curl jq man-db manpages procps file ripgrep ffmpeg \
libpq5 git curl jq man-db manpages procps file ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
+5 -6
View File
@@ -40,7 +40,7 @@ api_key = ""
smart_approvals = false # auto-approve high-confidence "approve" LLM verdicts (opt-in)
confidence_threshold = 0.95 # Smart Approvals auto-approve bar (LLM recommendation=approve)
max_context_ratio = 0.5 # max % of judge context window for history
timeout = 120.0 # seconds (generous for local models)
timeout = 60.0 # seconds (generous for local models)
read_only_tools = true # judge can use read_file/list_directory
cancel_on_approval = false # stop judging remaining tool calls once user decides
```
@@ -71,7 +71,7 @@ All fields are optional. The judge is enabled by default; use `enabled = false`
--judge / --no-judge Enable/disable (default: enabled)
--judge-model MODEL Model for judge
--judge-provider PROVIDER Provider for judge
--judge-timeout SECONDS LLM judge timeout (default: 120)
--judge-timeout SECONDS LLM judge timeout (default: 60)
--judge-confidence FLOAT Confidence threshold, 0-1 (default: 0.95)
```
@@ -193,10 +193,9 @@ Security hardening blocks access to sensitive paths:
### Timeout
The `timeout` setting (default 120 seconds) applies **per turn**, not as a total
budget across turns — each of the up to 5 turns gets a fresh budget, so a slow
earlier turn doesn't starve later ones. If a turn's budget expires, the judge
attempts to parse whatever partial response is available.
The `timeout` setting (default 60 seconds) is a total budget across all judge
turns. Time is decremented after each LLM call. If the budget expires mid-turn,
the judge attempts to parse whatever partial response is available.
---
+2 -5
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.6.9"
version = "1.7.0a2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "Apache-2.0"
@@ -95,10 +95,7 @@ include = [
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"live: requires a running LLM backend",
"allow_thread_leak: test intentionally leaves a background thread running (opts out of the leaked-thread guard)",
]
markers = ["live: requires a running LLM backend"]
filterwarnings = [
# mcp v1 deprecates streamablehttp_client for an entry point whose call
# shape only settles in v2 — adoption rides the deliberate v2 migration
+51 -51
View File
@@ -55,14 +55,14 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
"@tybys/wasm-util": "^0.10.2"
},
"funding": {
"type": "github",
@@ -409,16 +409,16 @@
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -427,13 +427,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.8",
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -454,9 +454,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -467,13 +467,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.8",
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
@@ -481,14 +481,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -497,9 +497,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -507,13 +507,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -921,9 +921,9 @@
}
},
"node_modules/obug": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
@@ -1200,19 +1200,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -1240,12 +1240,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.1.8",
"@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
-101
View File
@@ -1,118 +1,17 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import threading
import time
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
import pytest
def stop_loop_thread(loop: asyncio.AbstractEventLoop, thread: threading.Thread) -> None:
"""Fully tear down a ``loop.run_forever``-in-a-thread test loop.
Shuts the loop's default executor down ON the loop (joining its worker
threads — the ``asyncio_N`` threads that otherwise leak past the test),
then stops the loop, joins the thread, and closes the loop. Use in the
``finally`` of a background-loop fixture so nothing outlives the test.
"""
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(loop.shutdown_default_executor(), loop).result(timeout=5)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=5)
with contextlib.suppress(Exception):
loop.close()
def serve_until_exit(server: Any) -> None:
"""Run a uvicorn ``Server`` on a fresh event loop until it exits.
The thread target for an in-thread test upstream: when ``server.serve()``
returns (the fixture set ``server.should_exit`` / ``force_exit``), the loop
is closed so it doesn't leak past the fixture.
"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(server.serve())
finally:
# Cancel + drain anything the app left pending (e.g. sse_starlette's
# shutdown watcher) so loop.close() doesn't warn "Task was destroyed
# but it is pending".
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()
if TYPE_CHECKING:
from collections.abc import Iterator
from turnstone.core.mcp_client import MCPClientManager, StaticServerState
from turnstone.core.mcp_crypto import MCPTokenCipher
from turnstone.core.oidc import OIDCConfig
# A background daemon (e.g. title generation) can log into pytest's per-test
# capture as it is torn down — a benign "I/O operation on closed file" handler
# error. Don't let the logging module turn that race into noisy stderr
# tracebacks. (Process-global, test-only — product runtime keeps the default.)
logging.raiseExceptions = False
# Threads a test leaves running after teardown bleed into LATER tests' captured
# output (the "I/O operation on closed file" heisenbug) and, worse, can wedge
# the whole run (a leaked event loop / server that never stops). This grace
# lets a legitimately-finishing quick daemon settle before we judge a leak.
_THREAD_LEAK_GRACE = 5.0
@pytest.fixture(autouse=True)
def _no_leaked_threads(request: pytest.FixtureRequest) -> Iterator[None]:
"""Fail a test that leaves a background thread running past teardown.
Snapshots the live threads at setup; at teardown, gives any NEW thread a
short grace to finish, then fails listing those still alive — so a leak is
caught here instead of as a heisenbug days later. Opt out with
``@pytest.mark.allow_thread_leak`` (e.g. module-scoped servers in the live
suite).
"""
if request.node.get_closest_marker("allow_thread_leak"):
yield
return
# Snapshot the Thread OBJECTS, not their idents: Thread.ident is recycled
# after a thread exits, so an ident-based snapshot could mistake a new
# leaked thread (reusing an exited thread's ident) for a pre-existing one.
before = set(threading.enumerate())
yield
main = threading.main_thread()
current = threading.current_thread()
# One deadline shared across all joined threads — a deliberate TOTAL
# teardown budget (not per-thread), so a pathological test can't stall
# teardown by N×grace. A genuine never-stopping leak exhausts it and fails.
deadline = time.monotonic() + _THREAD_LEAK_GRACE
leaked = []
for t in threading.enumerate():
if t in before or t is main or t is current or not t.is_alive():
continue
t.join(timeout=max(0.0, deadline - time.monotonic()))
if t.is_alive():
leaked.append(t.name)
if leaked:
pytest.fail(
f"test left background threads running after teardown: {leaked}. "
"Stop them in teardown (shut down servers / close event loops / join "
"threads), or mark @pytest.mark.allow_thread_leak if intentional."
)
def make_mcp_token_cipher() -> MCPTokenCipher:
"""Build a single-key MCP token cipher for tests.
+5 -202
View File
@@ -7,7 +7,6 @@ helper code runs end-to-end without a network call.
from __future__ import annotations
import shutil
from unittest.mock import MagicMock
import pytest
@@ -19,16 +18,11 @@ class _Cfg:
"""Stand-in for ModelConfig — only the fields audio.py reads."""
def __init__(
self,
model: str,
capabilities: dict | None = None,
provider: str = "openai",
server_compat: dict | None = None,
self, model: str, capabilities: dict | None = None, provider: str = "openai"
) -> None:
self.model = model
self.capabilities = capabilities or {}
self.provider = provider
self.server_compat = server_compat or {}
class _FakeConfigStore:
@@ -197,8 +191,7 @@ class TestTranscribe:
with pytest.raises(audio.AudioBackendError):
audio.transcribe(registry=reg, alias="voice", data=b"x", filename="a.wav")
def test_omni_model_transcribes_via_chat(self, monkeypatch):
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
def test_omni_model_transcribes_via_chat(self):
client = MagicMock()
msg = MagicMock(content=" the transcript ")
client.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=msg)])
@@ -209,18 +202,15 @@ class TestTranscribe:
assert res.transcript == "the transcript"
# The dedicated transcription endpoint is NOT used for an omni model.
client.audio.transcriptions.create.assert_not_called()
# Audio rides as an input_audio chat part; format comes from the filename.
parts = client.chat.completions.create.call_args.kwargs["messages"][0]["content"]
# Prompt precedes the audio part — the order Gemma documents for transcription.
assert [p["type"] for p in parts] == ["text", "input_audio"]
# The clip is transcoded to wav regardless of the upload container.
audio_part = next(p for p in parts if p["type"] == "input_audio")
assert audio_part["input_audio"]["format"] == "wav"
assert audio_part["input_audio"]["format"] == "webm"
# A blank prompt falls back to the omni STT default instruction.
text_part = next(p for p in parts if p["type"] == "text")
assert "Only output the transcription" in text_part["text"]
def test_omni_prompt_override_used(self, monkeypatch):
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
def test_omni_prompt_override_used(self):
client = MagicMock()
client.chat.completions.create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="x"))]
@@ -348,190 +338,3 @@ class TestTranscribeCached:
assert audio.transcribe_cached(**kw) == ""
audio.transcribe_cached(**kw)
assert len(calls) == 2 # failure not cached -> retried
# ---------------------------------------------------------------------------
# Omni chat request shaping — transcode + thinking-off + token cap
# ---------------------------------------------------------------------------
class TestOmniChatExtraBody:
"""``_omni_chat_extra_body`` re-applies what the raw-client STT path skips."""
_THINKING = {"thinking_mode": "manual", "thinking_param": "enable_thinking"}
def test_disables_thinking_via_model_param(self):
cfg = _Cfg("gemma", dict(self._THINKING))
assert audio._omni_chat_extra_body(cfg) == {
"chat_template_kwargs": {"enable_thinking": False}
}
def test_thinking_off_wins_over_operator_flag(self):
cfg = _Cfg(
"gemma",
dict(self._THINKING),
server_compat={"extra_body": {"chat_template_kwargs": {"enable_thinking": True}}},
)
# STT never wants reasoning, even if an operator stored thinking on.
assert audio._omni_chat_extra_body(cfg)["chat_template_kwargs"]["enable_thinking"] is False
def test_forwards_operator_server_compat_extra_body(self):
cfg = _Cfg(
"model",
dict(self._THINKING),
server_compat={"extra_body": {"reasoning_format": "auto"}},
)
extra = audio._omni_chat_extra_body(cfg)
assert extra["reasoning_format"] == "auto"
assert extra["chat_template_kwargs"] == {"enable_thinking": False}
def test_empty_for_non_thinking_model(self):
cfg = _Cfg("omni", {"supports_audio_input": True})
assert audio._omni_chat_extra_body(cfg) == {}
class TestOmniChatCall:
"""The omni chat call carries the thinking-off extra_body and a token cap."""
def test_sends_thinking_off_and_token_cap(self, monkeypatch):
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
client = MagicMock()
client.chat.completions.create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="hi"))]
)
cfg = _Cfg(
"gemma-omni",
{
"supports_audio_input": True,
"thinking_mode": "manual",
"thinking_param": "enable_thinking",
},
)
audio.transcribe(
registry=_FakeRegistry("omni", cfg, client),
alias="omni",
data=b"webmbytes",
filename="speech.webm",
)
kwargs = client.chat.completions.create.call_args.kwargs
assert kwargs["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False
assert kwargs["max_tokens"] == audio._OMNI_STT_MAX_TOKENS
class TestTranscode:
"""``_to_wav_16k_mono`` normalizes any container to 16 kHz mono WAV via ffmpeg."""
def _stereo_wav_44k(self) -> bytes:
import io
import wave
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(44100)
w.writeframes(b"\x00\x01\x00\x01" * 4410) # 0.1 s of stereo
return buf.getvalue()
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
def test_transcodes_to_16k_mono(self):
import io
import wave
out = audio._to_wav_16k_mono(self._stereo_wav_44k())
with wave.open(io.BytesIO(out), "rb") as w:
assert w.getnchannels() == 1
assert w.getframerate() == 16000
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
def test_undecodable_bytes_raise_backend_error(self):
with pytest.raises(audio.AudioBackendError):
audio._to_wav_16k_mono(b"this is not audio at all")
def test_missing_ffmpeg_raises_backend_error(self, monkeypatch):
def _no_ffmpeg(*a, **k):
raise FileNotFoundError("ffmpeg")
monkeypatch.setattr(audio.subprocess, "run", _no_ffmpeg)
with pytest.raises(audio.AudioBackendError, match="ffmpeg is not installed"):
audio._to_wav_16k_mono(b"x")
def test_invokes_ffmpeg_with_hardened_argv(self, monkeypatch):
# Covers the argv shaping even on a CI image without ffmpeg installed.
captured = {}
def _fake_run(cmd, **kwargs):
captured["cmd"] = cmd
captured["input"] = kwargs.get("input")
return MagicMock(returncode=0, stdout=b"RIFF....WAVE", stderr=b"")
monkeypatch.setattr(audio.subprocess, "run", _fake_run)
assert audio._to_wav_16k_mono(b"rawclip") == b"RIFF....WAVE"
cmd = captured["cmd"]
assert cmd[0] == "ffmpeg"
assert captured["input"] == b"rawclip"
# SSRF/decompression-bomb hardening + the 16 kHz mono normalization.
assert cmd[cmd.index("-protocol_whitelist") + 1] == "pipe"
assert "-vn" in cmd
assert cmd[cmd.index("-ac") + 1] == "1"
assert cmd[cmd.index("-ar") + 1] == "16000"
assert cmd[cmd.index("-f") + 1] == "wav"
def test_nonzero_returncode_raises_backend_error(self, monkeypatch):
monkeypatch.setattr(
audio.subprocess,
"run",
lambda *a, **k: MagicMock(returncode=1, stdout=b"", stderr=b"boom"),
)
with pytest.raises(audio.AudioBackendError, match="Audio transcode failed"):
audio._to_wav_16k_mono(b"x")
def _stream_chunk(content):
return MagicMock(choices=[MagicMock(delta=MagicMock(content=content))])
class TestTranscribeStream:
"""``transcribe_stream`` yields content deltas; resolve/transcode are eager."""
def test_streams_chat_deltas_with_thinking_off(self, monkeypatch):
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
client = MagicMock()
client.chat.completions.create.return_value = iter(
[_stream_chunk("and so"), _stream_chunk(None), _stream_chunk(" my fellow americans")]
)
cfg = _Cfg(
"gemma-omni",
{
"supports_audio_input": True,
"thinking_mode": "manual",
"thinking_param": "enable_thinking",
},
)
gen = audio.transcribe_stream(
registry=_FakeRegistry("omni", cfg, client), alias="omni", data=b"webmbytes"
)
# Empty/None deltas are skipped; the rest stream through in order.
assert list(gen) == ["and so", " my fellow americans"]
kwargs = client.chat.completions.create.call_args.kwargs
assert kwargs["stream"] is True
assert kwargs["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False
def test_non_audio_provider_raises_before_streaming(self):
client = MagicMock()
cfg = _Cfg("gemma", {"supports_audio_input": True}, provider="anthropic-compatible")
with pytest.raises(audio.AudioUnavailableError, match="OpenAI-compatible provider"):
audio.transcribe_stream(
registry=_FakeRegistry("omni", cfg, client), alias="omni", data=b"x"
)
client.chat.completions.create.assert_not_called()
def test_whisper_alias_emits_single_chunk(self):
client = MagicMock()
client.audio.transcriptions.create.return_value = MagicMock(text=" full transcript ")
cfg = _Cfg("whisper-1") # name inference -> dedicated endpoint, no chat stream
gen = audio.transcribe_stream(
registry=_FakeRegistry("w", cfg, client), alias="w", data=b"x"
)
assert list(gen) == ["full transcript"]
client.chat.completions.create.assert_not_called()
-53
View File
@@ -85,59 +85,6 @@ def test_emit_created_calls_collector_with_coord_fields() -> None:
)
def test_emit_created_seeds_resolved_display_name(tmp_path: Any) -> None:
"""The collector seed uses the resolved display name (alias > title >
name), not the synthetic ``ws.name``. A coordinator carrying a
persisted LLM auto-title (written by ``update_workstream_title``) then
shows that title in the live cluster tree instead of reverting to
``ws-xxxx``. Regression guard for the adapter half of the
coordinator-title-persistence fix — the server-side ``_coordinator_rows``
half is pinned in test_coordinator_endpoints.py."""
from turnstone.core.storage import init_storage, reset_storage
reset_storage()
backend = init_storage("sqlite", path=str(tmp_path / "adapter.db"), run_migrations=False)
try:
# Titled coordinator → the title surfaces over the placeholder name.
backend.register_workstream(
"coord-1",
node_id="console",
user_id="u1",
name="ws-c0c0",
kind=WorkstreamKind.COORDINATOR,
)
backend.update_workstream_title("coord-1", "Investigate the title bug")
adapter, collector = _make_adapter()
adapter.emit_created(_make_ws(name="ws-c0c0"))
assert (
collector.emit_console_ws_created.call_args.kwargs["name"]
== "Investigate the title bug"
)
# A user alias outranks the auto-title (alias > title > name).
assert backend.set_workstream_alias("coord-1", "Pinned name")
collector.emit_console_ws_created.reset_mock()
adapter._fanout_console_ws_created(_make_ws(name="ws-c0c0"))
assert collector.emit_console_ws_created.call_args.kwargs["name"] == "Pinned name"
finally:
reset_storage()
def test_coord_display_name_skips_uninitialized_storage() -> None:
"""_coord_display_name runs on a lifecycle-event path and must NOT trip
get_storage()'s SQLite auto-init (a stray .turnstone.db in the CWD) when
storage isn't initialized — it falls back to the placeholder ws.name and
leaves storage untouched."""
from turnstone.console.coordinator_adapter import _coord_display_name
from turnstone.core.storage import is_storage_initialized, reset_storage
reset_storage()
assert not is_storage_initialized()
assert _coord_display_name(_make_ws(name="ws-abcd")) == "ws-abcd"
# The resolution did not auto-initialize storage as a side effect.
assert not is_storage_initialized()
def test_emit_state_calls_collector_state() -> None:
"""Post-rich-payload, emit_state passes tokens / context_ratio /
activity / activity_state / content kwargs read from ws.ui's
-168
View File
@@ -65,10 +65,8 @@ from turnstone.core.session_routes import (
make_history_handler,
make_list_handler,
make_open_handler,
make_refresh_title_handler,
make_saved_handler,
make_send_handler,
make_set_title_handler,
)
from turnstone.core.workstream import WorkstreamKind
@@ -206,16 +204,6 @@ def _make_client(
),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/refresh-title",
make_refresh_title_handler(_coord_endpoint_config),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/title",
make_set_title_handler(_coord_endpoint_config),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/history",
make_history_handler(_coord_endpoint_config),
@@ -382,114 +370,6 @@ def test_unresolvable_alias_returns_503(storage):
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
# ---------------------------------------------------------------------------
# Title verbs — refresh-title (LLM regenerate) + set title (manual alias),
# ported to coordinators via the lifted make_refresh_title_handler /
# make_set_title_handler factories so both kinds share one body.
# ---------------------------------------------------------------------------
def test_coord_refresh_title_triggers_regeneration(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(f"/v1/api/workstreams/{ws.id}/refresh-title", headers=_COORD_HEADERS)
assert resp.status_code == 200
# The lifted handler resolves the current display name and asks the
# live session to regenerate a (different) title in the background.
ws.session.request_title_refresh.assert_called_once_with("c1")
def test_coord_refresh_title_requires_operator_permission(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/refresh-title",
headers={"X-Test-User": "user-1", "X-Test-Perms": "read"},
)
assert resp.status_code == 403
ws.session.request_title_refresh.assert_not_called()
def test_coord_refresh_title_unknown_ws_404(storage):
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
"/v1/api/workstreams/" + ("0" * 32) + "/refresh-title", headers=_COORD_HEADERS
)
assert resp.status_code == 404
def test_coord_set_title_stores_alias_and_broadcasts(storage):
from turnstone.core.memory import get_workstream_display_name
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/title",
json={"title": "Nightly migration sweep"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["title"] == "Nightly migration sweep"
# Stored as the alias (outranks the auto-title) ...
assert get_workstream_display_name(ws.id) == "Nightly migration sweep"
# ... and broadcast live to the dashboard via the session UI.
ws.session.ui.on_rename.assert_called_once_with("Nightly migration sweep")
def test_coord_set_title_empty_400(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/title", json={"title": " "}, headers=_COORD_HEADERS
)
assert resp.status_code == 400
def test_coord_set_title_alias_conflict_409(storage):
mgr = _build_mgr(storage)
first = mgr.create(user_id="user-1", name="c1")
second = mgr.create(user_id="user-1", name="c2")
storage.set_workstream_alias(first.id, "taken")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{second.id}/title", json={"title": "taken"}, headers=_COORD_HEADERS
)
assert resp.status_code == 409
def test_coord_set_title_rejects_unowned_ws_404(storage):
"""An admin.coordinator operator can't rename a workstream the coord
manager doesn't own (here a cross-kind interactive row) via the coord
/title route: set_workstream_alias is a global kind-unscoped UPDATE, so
the handler 404s on the in-memory coord lookup BEFORE writing — no
silent 200, no cross-kind alias write."""
from turnstone.core.memory import get_workstream_display_name
mgr = _build_mgr(storage)
# An interactive-kind row in storage, NOT held by coord_mgr.
storage.register_workstream(
"i" * 32,
node_id="node-1",
user_id="user-1",
name="interactive-ws",
kind=WorkstreamKind.INTERACTIVE,
)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{'i' * 32}/title",
json={"title": "hijacked"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
# The interactive ws's display name is untouched — the alias write never fired.
assert get_workstream_display_name("i" * 32) == "interactive-ws"
def test_active_list_row_shape_includes_unified_fields(storage):
"""Stage 2 list-verb-lift parity regression — coord active-list row
carries the always-include fields (ws_id, name, state, kind,
@@ -2553,54 +2433,6 @@ def test_coordinator_rows_persisted_cluster_wide(storage):
assert {r["name"] for r in rows} == {"alice-closed", "bob-closed", "orphan-closed"}
def test_coordinator_rows_surface_persisted_title(storage):
"""Regression for the coordinator-title-persistence bug.
The LLM auto-title (``update_workstream_title``) and the user alias
(``set_workstream_alias``) live only in ``workstreams.title`` /
``workstreams.alias``. ``_coordinator_rows`` must resolve the
display name ``alias > title > name`` from the persisted row for BOTH
lanes — the in-memory ``ws.name`` is the synthetic ``ws-xxxx``
placeholder. Before the fix the read path hardcoded ``title=""`` and
used ``ws.name`` / the ``name`` column, so a generated title was
written but never read back: it reverted to ``ws-xxxx`` on every
dashboard refresh."""
from turnstone.console.server import _coordinator_rows
from turnstone.core.workstream import WorkstreamKind
mgr = _build_mgr(storage)
# In-memory lane: a LIVE coordinator titled after creation. The
# manager assigned the placeholder ``ws.name``; the title is in the DB.
live = mgr.create(user_id="alice", name="ws-abcd")
storage.update_workstream_title(live.id, "Refactor the auth layer")
# Persisted lane: a closed coordinator (evicted from the manager)
# carrying BOTH a title and a user alias — the alias must win.
storage.register_workstream(
"f" * 32,
node_id="console",
user_id="bob",
name="ws-f0f0",
state="closed",
kind=WorkstreamKind.COORDINATOR,
parent_ws_id=None,
)
storage.update_workstream_title("f" * 32, "auto-generated title")
assert storage.set_workstream_alias("f" * 32, "Bob's pinned name")
request = _persisted_rows_request(storage, mgr, "alice", frozenset({"read"}))
rows = {r["id"]: r for r in _coordinator_rows(request)}
live_row = rows[live.id]
assert live_row["name"] == "Refactor the auth layer"
assert live_row["title"] == "Refactor the auth layer"
closed_row = rows["f" * 32]
assert closed_row["name"] == "Bob's pinned name" # alias > title > name
assert closed_row["title"] == "auto-generated title"
# ---------------------------------------------------------------------------
# Stage 2 P1.5 — coord attachment surface parity with interactive
# ---------------------------------------------------------------------------
-66
View File
@@ -1,66 +0,0 @@
"""Tests for turnstone.core.deadline.run_with_deadline.
The load-bearing property is the daemon worker: on timeout or cancel the call
is abandoned, and the abandoned thread must be a daemon so it can never block
interpreter exit (the bug that motivated the helper — a non-daemon
ThreadPoolExecutor worker is joined by concurrent.futures' atexit hook).
"""
from __future__ import annotations
import threading
import time
import pytest
from turnstone.core.deadline import (
DeadlineCancelledError,
DeadlineExceededError,
run_with_deadline,
)
def test_returns_result_on_success() -> None:
assert run_with_deadline(lambda: 42, timeout=1.0) == 42
def test_reraises_callable_exception() -> None:
def boom() -> None:
raise ValueError("upstream failed")
with pytest.raises(ValueError, match="upstream failed"):
run_with_deadline(boom, timeout=1.0)
def test_timeout_returns_promptly_and_abandons_a_daemon_worker() -> None:
# The worker sleeps far past the deadline; the call must return promptly
# via DeadlineExceededError, and the abandoned worker must be a daemon so
# it cannot pin interpreter exit.
start = time.monotonic()
with pytest.raises(DeadlineExceededError):
run_with_deadline(lambda: time.sleep(2.0), timeout=0.2, poll=0.05, thread_name="dl-timeout")
assert time.monotonic() - start < 1.0
stragglers = [t for t in threading.enumerate() if t.name == "dl-timeout" and not t.daemon]
assert stragglers == [], f"non-daemon worker survived: {stragglers}"
def test_cancel_returns_promptly() -> None:
cancel = threading.Event()
def _fire() -> None:
time.sleep(0.1)
cancel.set()
threading.Thread(target=_fire, daemon=True).start()
start = time.monotonic()
with pytest.raises(DeadlineCancelledError):
run_with_deadline(
lambda: time.sleep(2.0),
timeout=10.0,
cancel_event=cancel,
poll=0.05,
thread_name="dl-cancel",
)
assert time.monotonic() - start < 1.0
stragglers = [t for t in threading.enumerate() if t.name == "dl-cancel" and not t.daemon]
assert stragglers == [], f"non-daemon worker survived: {stragglers}"
+21 -40
View File
@@ -52,32 +52,13 @@ class _Handler(http.server.BaseHTTPRequestHandler):
pass
@pytest.fixture
def serve():
"""Factory that starts an HTTP(S) server on an ephemeral port and returns
that port.
Every server it starts is shut down + its serve_forever thread joined at
teardown, so the thread never outlives the test (which would otherwise bleed
into a later test's captured output / leak the listener).
"""
started: list[tuple[http.server.HTTPServer, threading.Thread]] = []
def _factory(handler_cls, ssl_context: ssl.SSLContext | None = None) -> int:
httpd = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
if ssl_context is not None:
httpd.socket = ssl_context.wrap_socket(httpd.socket, server_side=True)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
started.append((httpd, thread))
return httpd.server_address[1]
yield _factory
for httpd, thread in started:
httpd.shutdown() # break the serve_forever loop
httpd.server_close() # release the listening socket
thread.join(timeout=5)
def _serve(handler_cls, ssl_context: ssl.SSLContext | None = None) -> int:
"""Start a daemon-thread HTTP(S) server on an ephemeral port."""
httpd = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
if ssl_context is not None:
httpd.socket = ssl_context.wrap_socket(httpd.socket, server_side=True)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd.server_address[1]
@pytest.fixture
@@ -109,29 +90,29 @@ def mtls_setup(tmp_path):
# ── Plain HTTP (mTLS disabled — the default deployment) ─────────────────────
def test_plain_http_ok(serve):
def test_plain_http_ok():
"""Default path: plain probe succeeds, PEM dir never consulted."""
port = serve(_Handler)
port = _serve(_Handler)
result = run_healthcheck(f"http://127.0.0.1:{port}/health")
assert result.returncode == 0, result.stderr
def test_plain_http_degraded_is_healthy(serve):
def test_plain_http_degraded_is_healthy():
"""'degraded' (backend down, server up) still counts as container-healthy."""
class Degraded(_Handler):
payload = {"status": "degraded"}
port = serve(Degraded)
port = _serve(Degraded)
result = run_healthcheck(f"http://127.0.0.1:{port}/health")
assert result.returncode == 0, result.stderr
def test_plain_http_bad_status_fails(serve):
def test_plain_http_bad_status_fails():
class Bad(_Handler):
payload = {"status": "error"}
port = serve(Bad)
port = _serve(Bad)
result = run_healthcheck(f"http://127.0.0.1:{port}/health")
assert result.returncode == 1
assert "unhealthy payload" in result.stderr
@@ -147,40 +128,40 @@ def test_server_down_fails():
# ── mTLS (tls.enabled) ───────────────────────────────────────────────────────
def test_mtls_probe_with_pem_dir(mtls_setup, serve):
def test_mtls_probe_with_pem_dir(mtls_setup):
"""The regression case: mTLS node + plain-HTTP probe URL.
The plain attempt is rejected at the socket; the script must fall back
to HTTPS with the node cert as client cert and report healthy."""
pem_root, server_ctx = mtls_setup
port = serve(_Handler, ssl_context=server_ctx)
port = _serve(_Handler, ssl_context=server_ctx)
result = run_healthcheck(f"http://127.0.0.1:{port}/health", pem_root=pem_root)
assert result.returncode == 0, result.stderr
def test_mtls_probe_without_pems_fails(mtls_setup, serve):
def test_mtls_probe_without_pems_fails(mtls_setup):
"""mTLS node but no PEM material on disk: the probe must fail."""
_, server_ctx = mtls_setup
port = serve(_Handler, ssl_context=server_ctx)
port = _serve(_Handler, ssl_context=server_ctx)
result = run_healthcheck(f"http://127.0.0.1:{port}/health", pem_root=None)
assert result.returncode == 1
assert "Health check failed" in result.stderr
def test_mtls_unhealthy_payload_fails(mtls_setup, serve):
def test_mtls_unhealthy_payload_fails(mtls_setup):
"""A reachable mTLS server with a bad payload is still unhealthy."""
pem_root, server_ctx = mtls_setup
class Bad(_Handler):
payload = {"status": "error"}
port = serve(Bad, ssl_context=server_ctx)
port = _serve(Bad, ssl_context=server_ctx)
result = run_healthcheck(f"http://127.0.0.1:{port}/health", pem_root=pem_root)
assert result.returncode == 1
assert "unhealthy payload" in result.stderr
def test_mtls_incomplete_pem_dir_fails(mtls_setup, tmp_path, serve):
def test_mtls_incomplete_pem_dir_fails(mtls_setup, tmp_path):
"""A PEM dir missing the key is skipped, not half-used."""
_, server_ctx = mtls_setup
incomplete = tmp_path / "incomplete-root"
@@ -189,7 +170,7 @@ def test_mtls_incomplete_pem_dir_fails(mtls_setup, tmp_path, serve):
(d / "fullchain.pem").write_text("not a cert")
(d / "ca.pem").write_text("not a cert")
port = serve(_Handler, ssl_context=server_ctx)
port = _serve(_Handler, ssl_context=server_ctx)
result = run_healthcheck(f"http://127.0.0.1:{port}/health", pem_root=incomplete)
assert result.returncode == 1
+61 -44
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
@@ -119,7 +120,8 @@ class TestVerdictParsing:
[{"role": "user", "content": "Run echo hello"}],
callback_results.append,
)
_wait_for(callback_results, 1)
# Wait for daemon thread
time.sleep(0.5)
assert len(heuristics) == 1
assert heuristics[0].tier == "heuristic"
@@ -182,12 +184,14 @@ class TestErrorHandling:
provider = _make_mock_provider(side_effect=RuntimeError("API error"))
judge = _make_judge(provider)
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
client=MagicMock(),
)
with ThreadPoolExecutor(max_workers=1) as pool:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert result is None
def test_provider_error_heuristic_still_returned(self):
@@ -205,7 +209,7 @@ class TestErrorHandling:
[{"role": "user", "content": "test"}],
callback_results.append,
)
_wait_for(callback_results, 1)
time.sleep(0.5)
assert len(heuristics) == 1
assert heuristics[0].tier == "heuristic"
@@ -229,19 +233,20 @@ class TestErrorHandling:
[{"role": "user", "content": "test"}],
callback_results.append,
)
_wait_for(callback_results, 1)
time.sleep(0.5)
assert len(callback_results) == 1
assert callback_results[0].tier == "llm_fallback"
def test_evaluate_single_none_delivers_fallback(self):
"""A judge-call timeout now surfaces as ``_evaluate_single`` returning
None (the executor-poison restart dance is gone); the daemon must still
deliver exactly one fallback for that item — Smart Approvals waits on
the full verdict set before gating, so a silently-skipped item would
block that wait until its timeout."""
def test_executor_poison_delivers_fallback(self):
"""An _ExecutorPoisonedError (a judge-call timeout poisoning the
single-worker executor) restarts the executor AND still delivers one
fallback for the interrupted item — the twin of the generic-exception
path, and load-bearing for Smart Approvals' batch-completeness wait."""
from turnstone.core.judge import _ExecutorPoisonedError
judge = _make_judge()
judge._evaluate_single = MagicMock( # type: ignore[method-assign]
return_value=None
side_effect=_ExecutorPoisonedError()
)
callback_results: list[IntentVerdict] = []
judge.evaluate(
@@ -249,7 +254,7 @@ class TestErrorHandling:
[{"role": "user", "content": "test"}],
callback_results.append,
)
_wait_for(callback_results, 1)
time.sleep(0.5)
assert len(callback_results) == 1
assert callback_results[0].tier == "llm_fallback"
@@ -261,12 +266,14 @@ class TestErrorHandling:
result_mock.content = ""
judge = _make_judge(provider)
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
client=MagicMock(),
)
with ThreadPoolExecutor(max_workers=1) as pool:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert result is None
def test_empty_content_length_stop_no_retry(self):
@@ -278,12 +285,14 @@ class TestErrorHandling:
result_mock.finish_reason = "length"
judge = _make_judge(provider)
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
client=MagicMock(),
)
with ThreadPoolExecutor(max_workers=1) as pool:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert result is None
# Should have been called exactly once — no retries
assert provider.create_completion.call_count == 1
@@ -395,12 +404,14 @@ class TestMultiTurnToolUse:
provider.create_completion.side_effect = [turn1, turn2]
judge = _make_judge(provider)
verdict = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
client=MagicMock(),
)
with ThreadPoolExecutor(max_workers=1) as pool:
verdict = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert verdict is not None
assert verdict.tier == "llm"
assert provider.create_completion.call_count == 2
@@ -443,12 +454,14 @@ class TestMultiTurnToolUse:
]
judge = _make_judge(provider)
judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
client=MagicMock(),
)
with ThreadPoolExecutor(max_workers=1) as pool:
judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
assert provider.create_completion.call_count == 5
@@ -494,7 +507,7 @@ class TestConfidenceArbitration:
[{"role": "user", "content": "Run echo hello"}],
callback_results.append,
)
_wait_for(callback_results, 1)
time.sleep(0.5)
assert len(heuristics) == 1
assert heuristics[0].confidence == 0.85
@@ -514,7 +527,7 @@ class TestConfidenceArbitration:
[{"role": "user", "content": "Run echo hello"}],
callback_results.append,
)
_wait_for(callback_results, 1)
time.sleep(0.5)
assert len(heuristics) == 1
# LLM verdict is always delivered regardless of confidence comparison
@@ -953,7 +966,11 @@ class TestModelAliasResolution:
[{"role": "user", "content": "delegate the audit"}],
callback_results.append,
)
_wait_for(callback_results, 1)
# Wait for daemon thread.
for _ in range(20):
if callback_results:
break
time.sleep(0.1)
assert callback_results, "judge never delivered a verdict"
assert callback_results[0].tier == "llm"
+7 -15
View File
@@ -38,7 +38,7 @@ import uvicorn
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from tests.conftest import make_mcp_token_cipher, serve_until_exit, stop_loop_thread
from tests.conftest import make_mcp_token_cipher
from turnstone.core.mcp_client import MCPClientManager
from turnstone.core.mcp_crypto import MCPTokenStore
from turnstone.core.mcp_oauth import TokenLookupResult
@@ -187,14 +187,7 @@ def _build_server(port: int, behaviour: dict[str, Any]) -> uvicorn.Server:
app = mcp.streamable_http_app()
app.add_middleware(BehaviorMiddleware, behaviour=behaviour)
config = uvicorn.Config(
app,
host="127.0.0.1",
port=port,
log_level="warning",
access_log=False,
timeout_graceful_shutdown=0, # don't block teardown on a held-open stream
)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", access_log=False)
return uvicorn.Server(config)
@@ -222,7 +215,9 @@ def upstream():
server = _build_server(port, behaviour)
def _run() -> None:
serve_until_exit(server)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
t = threading.Thread(target=_run, daemon=True, name="phase6-upstream")
t.start()
@@ -230,11 +225,7 @@ def upstream():
_wait_ready(port)
yield f"http://127.0.0.1:{port}/mcp", behaviour
finally:
# should_exit alone triggers a GRACEFUL shutdown that can wait forever
# on a held-open streamable-http stream; force_exit skips that wait so
# serve() returns and the upstream thread doesn't leak past the test.
server.should_exit = True
server.force_exit = True
t.join(timeout=5)
@@ -320,7 +311,8 @@ def running_loop_mgr():
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
stop_loop_thread(loop, thread)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
# ---------------------------------------------------------------------------
+3 -2
View File
@@ -34,7 +34,7 @@ from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from tests.conftest import make_mcp_token_cipher, stop_loop_thread
from tests.conftest import make_mcp_token_cipher
from turnstone.core.mcp_client import (
MCPClientManager,
_AuthCapture,
@@ -131,7 +131,8 @@ def running_loop_mgr():
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
stop_loop_thread(loop, thread)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any) -> Any:
+7 -15
View File
@@ -26,7 +26,7 @@ import uvicorn
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from tests.conftest import make_mcp_token_cipher, serve_until_exit, stop_loop_thread
from tests.conftest import make_mcp_token_cipher
from turnstone.core.mcp_client import MCPClientManager
from turnstone.core.mcp_crypto import MCPTokenStore
from turnstone.core.mcp_oauth import TokenLookupResult
@@ -130,14 +130,7 @@ def _build_server(port: int, behaviour: dict[str, Any]) -> uvicorn.Server:
app = mcp.streamable_http_app()
app.add_middleware(BehaviorMiddleware, behaviour=behaviour)
config = uvicorn.Config(
app,
host="127.0.0.1",
port=port,
log_level="warning",
access_log=False,
timeout_graceful_shutdown=0, # don't block teardown on a held-open stream
)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", access_log=False)
return uvicorn.Server(config)
@@ -159,7 +152,9 @@ def upstream():
server = _build_server(port, behaviour)
def _run() -> None:
serve_until_exit(server)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
t = threading.Thread(target=_run, daemon=True, name="phase7b-prompt-upstream")
t.start()
@@ -167,11 +162,7 @@ def upstream():
_wait_ready(port)
yield f"http://127.0.0.1:{port}/mcp", behaviour
finally:
# should_exit alone triggers a GRACEFUL shutdown that can wait forever
# on a held-open streamable-http stream; force_exit skips that wait so
# serve() returns and the upstream thread doesn't leak past the test.
server.should_exit = True
server.force_exit = True
t.join(timeout=5)
@@ -257,7 +248,8 @@ def running_loop_mgr():
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
stop_loop_thread(loop, thread)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
def _seed_pool_prompt_map(
@@ -26,7 +26,7 @@ import uvicorn
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from tests.conftest import make_mcp_token_cipher, serve_until_exit, stop_loop_thread
from tests.conftest import make_mcp_token_cipher
from turnstone.core.mcp_client import MCPClientManager
from turnstone.core.mcp_crypto import MCPTokenStore
from turnstone.core.mcp_oauth import TokenLookupResult
@@ -139,14 +139,7 @@ def _build_server(port: int, behaviour: dict[str, Any]) -> uvicorn.Server:
app = mcp.streamable_http_app()
app.add_middleware(BehaviorMiddleware, behaviour=behaviour)
config = uvicorn.Config(
app,
host="127.0.0.1",
port=port,
log_level="warning",
access_log=False,
timeout_graceful_shutdown=0, # don't block teardown on a held-open stream
)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", access_log=False)
return uvicorn.Server(config)
@@ -168,7 +161,9 @@ def upstream():
server = _build_server(port, behaviour)
def _run() -> None:
serve_until_exit(server)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
t = threading.Thread(target=_run, daemon=True, name="phase7b-resource-upstream")
t.start()
@@ -176,11 +171,7 @@ def upstream():
_wait_ready(port)
yield f"http://127.0.0.1:{port}/mcp", behaviour
finally:
# should_exit alone triggers a GRACEFUL shutdown that can wait forever
# on a held-open streamable-http stream; force_exit skips that wait so
# serve() returns and the upstream thread doesn't leak past the test.
server.should_exit = True
server.force_exit = True
t.join(timeout=5)
@@ -266,7 +257,8 @@ def running_loop_mgr():
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
stop_loop_thread(loop, thread)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
def _seed_pool_resource_map(
+3 -2
View File
@@ -25,7 +25,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from tests.conftest import make_mcp_token_cipher, stop_loop_thread
from tests.conftest import make_mcp_token_cipher
from turnstone.core.mcp_client import MCPClientManager, PoolEntryState
from turnstone.core.mcp_crypto import MCPTokenStore
from turnstone.core.storage._sqlite import SQLiteBackend
@@ -123,7 +123,8 @@ def running_loop_mgr():
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
stop_loop_thread(loop, thread)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any) -> Any:
-20
View File
@@ -220,26 +220,6 @@ class TestEvaluateFailurePaths:
# Cancel should return promptly, well below the 10s timeout.
assert elapsed < 2.0, f"cancel returned in {elapsed:.2f}s, expected < 2.0s"
def test_timeout_leaves_no_nondaemon_straggler(self) -> None:
# Regression: evaluate() abandons a slow upstream call on timeout, but
# the worker must be a *daemon* so it can never pin interpreter exit.
# The old ThreadPoolExecutor worker was non-daemon and got joined by
# concurrent.futures' atexit hook, hanging the whole test run at
# shutdown. See turnstone/core/deadline.py.
judge = _make_judge(
content='{"risk_level":"medium","flags":[],"reasoning":""}',
timeout=1.0,
delay=5.0,
)
v = judge.evaluate("payload", call_id="c1")
assert v.error == "timeout"
stragglers = [
t
for t in threading.enumerate()
if t.name.startswith("output-guard-judge") and not t.daemon
]
assert stragglers == [], f"non-daemon worker survived evaluate(): {stragglers}"
class TestAliasResolution:
def test_unknown_alias_falls_back_to_session_model(self) -> None:
+13 -20
View File
@@ -19,8 +19,7 @@ class TestSuggestProfile:
p = suggest_profile("vllm", "google/gemma-4-31B-it")
assert p["capabilities"]["thinking_mode"] == "manual"
assert p["capabilities"]["thinking_param"] == "enable_thinking"
# No bug-workaround extra_body — gemma-4 needs only the thinking param.
assert "extra_body" not in p["server_compat"]
assert p["server_compat"]["extra_body"]["skip_special_tokens"] is False
def test_vllm_gemma3(self) -> None:
p = suggest_profile("vllm", "google/gemma-3-27b-it")
@@ -148,14 +147,14 @@ class TestMergeServerCompat:
result = merge_server_compat(None, {"extra_body": {"skip_special_tokens": False}})
assert result == {"skip_special_tokens": False}
def test_full_server_compat_extra_body_no_base(self) -> None:
"""A server workaround (e.g. llama.cpp reasoning_format) forwards on its own."""
def test_full_vllm_gemma_compat_no_base(self) -> None:
"""vLLM workaround forwards on its own."""
compat = {
"server_type": "llama.cpp",
"extra_body": {"reasoning_format": "auto"},
"server_type": "vllm",
"extra_body": {"skip_special_tokens": False},
}
result = merge_server_compat(None, compat)
assert result == {"reasoning_format": "auto"}
assert result == {"skip_special_tokens": False}
def test_operator_chat_template_kwargs_only(self) -> None:
"""Operator can set chat_template_kwargs explicitly without seeding the base."""
@@ -211,27 +210,21 @@ class TestEndToEndRequestShaping:
"""Compose both layers — session builds extra_params, provider applies thinking."""
def test_vllm_gemma_full_flow(self) -> None:
"""Gemma now needs only the thinking param — no server workaround."""
"""Session forwards server workarounds, provider adds thinking param."""
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
server_compat = {"server_type": "vllm"}
server_compat = {
"server_type": "vllm",
"extra_body": {"skip_special_tokens": False},
}
# Step 1: session forwards (no auto-injection of reasoning_effort).
extra_params = merge_server_compat(None, server_compat)
# Step 2: provider injects thinking param into chat_template_kwargs.
extra_body = dict(extra_params)
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
assert extra_body == {"chat_template_kwargs": {"enable_thinking": True}}
def test_server_workaround_composes_with_thinking(self) -> None:
"""A top-level server workaround forwards alongside the injected thinking param."""
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
compat = {"server_type": "llama.cpp", "extra_body": {"reasoning_format": "auto"}}
extra_body = dict(merge_server_compat(None, compat))
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
assert extra_body == {
"chat_template_kwargs": {"enable_thinking": True},
"reasoning_format": "auto",
"skip_special_tokens": False,
}
def test_granite_thinking_key(self) -> None:
@@ -299,7 +292,7 @@ class TestProbeIntegration:
assert result["server_type"] == "vllm"
assert result["suggested_capabilities"]["thinking_mode"] == "manual"
assert result["suggested_capabilities"]["thinking_param"] == "enable_thinking"
assert "extra_body" not in result["suggested_server_compat"]
assert result["suggested_server_compat"]["extra_body"]["skip_special_tokens"] is False
def test_detect_non_thinking_no_suggested_capabilities(self) -> None:
"""Non-thinking vLLM model gets server_compat but no capabilities suggestion."""
-81
View File
@@ -150,27 +150,6 @@ def _send_with_mocks(session, responses, mock_execute, **extra_patches):
yield save_msg
def _capturing_thread_cls():
"""Return a no-op ``threading.Thread`` stand-in plus the list it records
each constructed thread's ``target`` into.
Patched over ``session.threading.Thread`` so a test can assert WHICH
callable was scheduled (e.g. ``_generate_title``) without the thread
actually running ``start()`` is a no-op, so no background LLM call
fires.
"""
started: list = []
class _CaptureThread:
def __init__(self, *a, target=None, **kw):
started.append(target)
def start(self):
pass
return _CaptureThread, started
def _user_pending(session) -> list[tuple[str, str]]:
"""Return user-channel queued nudges as ``(type, text)`` tuples.
@@ -1031,66 +1010,6 @@ class TestTitleRetry:
# Restore for cleanup
session._ws_id = original_ws_id
def test_title_fires_after_send_not_after_tool_free_turn(self, tmp_db):
"""Auto-title fires right after the user turn is recorded, BEFORE
tools run it no longer waits for a tool-call-free assistant
turn. Coordinators spend nearly every turn in tool calls and may
never reach that terminal text turn, so the old end-of-turn
trigger almost never fired for them (the timing half of the
coordinator-title bug)."""
session = _make_session()
assert session._title_generated is False
# The assistant's opening turn is ALL tool calls — under the old
# trigger no title would generate until a later text-only turn.
responses = [
{
"role": "assistant",
"content": "working",
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "echo", "arguments": "{}"},
}
],
},
{"role": "assistant", "content": "done"},
]
capture_cls, started = _capturing_thread_cls()
def mock_execute(_tool_calls):
# The title must already be scheduled by the time tools run.
assert session._title_generated is True
return [("c1", "ok")], None
with (
_send_with_mocks(session, responses, mock_execute),
patch("turnstone.core.session.threading.Thread", capture_cls),
):
session.send("refactor the auth layer")
assert session._title_generated is True
assert session._generate_title in started
def test_title_not_generated_for_blank_or_wake_send(self, tmp_db):
"""Blank input and synthetic wake sends don't burn the one-shot
auto-title ``_generate_title`` needs first-user-message text,
and a wake carries none."""
capture_cls, started = _capturing_thread_cls()
def mock_execute(_tool_calls):
return [], None
for user_input, kwargs in ((" ", {}), ("a real message", {"from_wake": True})):
session = _make_session()
with (
_send_with_mocks(session, [{"role": "assistant", "content": "ok"}], mock_execute),
patch("turnstone.core.session.threading.Thread", capture_cls),
):
session.send(user_input, **kwargs)
assert session._generate_title not in started
assert session._title_generated is False
class TestLiveConfigUpdate:
"""ConfigStore-backed sessions pick up settings changes at point-of-use."""
-21
View File
@@ -602,27 +602,6 @@ def test_step7_tab_menu_wired_per_persona() -> None:
)
def test_coordinator_tab_menu_enables_title_verbs() -> None:
"""Coordinators carry LLM/auto titles like interactive workstreams, so
their tab dropdown must surface Refresh/Edit title convTabMenu's
``titleVerbs`` block, POSTed to the console-origin coord
``refresh-title`` / ``title`` routes via the base-aware lane (default
base ""). Scoped to the coordinator registerType block so it can't
pass on the interactive pane's long-standing ``titleVerbs``."""
shell = _SHELL_JS.read_text(encoding="utf-8")
start = shell.index('registerType("coordinator"')
tail = shell[start:]
nxt = tail.find("registerType(", 1) # bound at the next pane registration
coord_block = tail[:nxt] if nxt != -1 else tail
assert "pane._ctl.closeSession()" in coord_block, (
"sanity: the extracted block is the coordinator pane"
)
assert "convTabMenu(" in coord_block, "the coordinator pane must wire a tab menu"
assert "titleVerbs: true" in coord_block, (
"the coordinator tab menu must enable titleVerbs (Refresh/Edit title)"
)
def test_tab_menu_base_aware_verb_lane() -> None:
"""Lifecycle round 2: a proxied interactive pane's tab menu must act on the
pane's OWN transport base, not the console origin — the globals lane only
+7 -9
View File
@@ -96,8 +96,10 @@ def _make_flaky_client(monkeypatch, failures: int):
"""TLSClient whose CA fetch fails ``failures`` times, then succeeds.
Returns (client, calls, sleeps) mutable lists recording each CA-fetch
attempt and each backoff delay (the client's backoff sleep is stubbed).
attempt and each backoff delay (asyncio.sleep is stubbed out).
"""
import asyncio
from turnstone.core.tls import TLSClient
client = TLSClient(
@@ -117,14 +119,11 @@ def _make_flaky_client(monkeypatch, failures: int):
pass
async def fake_sleep(delay):
# Stub the client's own _sleep seam, NOT the global asyncio.sleep:
# patching the global also intercepts any concurrent task sharing the
# event loop, which corrupted a background poller and hung CI.
sleeps.append(delay)
monkeypatch.setattr(client, "_fetch_ca_cert", flaky_fetch)
monkeypatch.setattr(client, "_request_cert", ok_request)
monkeypatch.setattr(client, "_sleep", fake_sleep)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
return client, calls, sleeps
@@ -176,6 +175,8 @@ async def test_init_retries_exhausted_raises(monkeypatch):
@pytest.mark.anyio
async def test_init_retries_discovery_failure(monkeypatch):
"""Console discovery (not-yet-registered console) is retried too."""
import asyncio
from turnstone.core.tls import TLSClient
client = TLSClient(storage=get_storage(), hostnames=["node-1"])
@@ -190,13 +191,10 @@ async def test_init_retries_discovery_failure(monkeypatch):
async def ok():
pass
async def fake_sleep(_delay):
pass
monkeypatch.setattr(client, "_discover_console_url", flaky_discover)
monkeypatch.setattr(client, "_fetch_ca_cert", ok)
monkeypatch.setattr(client, "_request_cert", ok)
monkeypatch.setattr(client, "_sleep", fake_sleep)
monkeypatch.setattr(asyncio, "sleep", lambda _: ok())
await client.init(attempts=2)
assert attempts == [1, 2]
-40
View File
@@ -1,40 +0,0 @@
"""Tests for turnstone.console.server._validate_regex_pattern.
The catastrophic-backtracking branch is verified by simulating the deadline
firing rather than running a real ReDoS regex a genuine runaway pattern would
leave a CPU-pinned daemon worker for the rest of the suite. The daemon-abandon
mechanism itself is covered in tests/test_deadline.py.
"""
from __future__ import annotations
from turnstone.console.server import _validate_regex_pattern
from turnstone.core.deadline import DeadlineExceededError
def test_valid_pattern_returns_none() -> None:
assert _validate_regex_pattern(r"\d{3}-\d{4}") is None
def test_invalid_pattern_returns_error() -> None:
msg = _validate_regex_pattern(r"(unclosed")
assert msg is not None
assert msg.startswith("Invalid regex")
def test_catastrophic_backtracking_returns_message(monkeypatch) -> None:
def _deadline(*_args, **_kwargs):
raise DeadlineExceededError
monkeypatch.setattr("turnstone.console.server.run_with_deadline", _deadline)
# The pattern is arbitrary — run_with_deadline is stubbed to raise, so the
# probe never runs; a real backtracking literal here would only trip CodeQL.
assert _validate_regex_pattern(r"\w+") == "Regex appears to have catastrophic backtracking"
def test_probe_error_returns_generic_message(monkeypatch) -> None:
def _err(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr("turnstone.console.server.run_with_deadline", _err)
assert _validate_regex_pattern(r"abc") == "Regex caused an error during test"
+5 -17
View File
@@ -31,17 +31,16 @@ from turnstone.core.session_routes import (
make_export_handler,
make_history_handler,
make_open_handler,
make_refresh_title_handler,
make_retry_handler,
make_rewind_handler,
make_set_title_handler,
)
from turnstone.core.storage._sqlite import SQLiteBackend
from turnstone.core.workstream import WorkstreamKind
from turnstone.server import (
_interactive_tenant_check,
delete_workstream_endpoint,
list_interface_settings,
refresh_workstream_title,
set_workstream_title,
update_interface_setting,
)
@@ -114,18 +113,6 @@ def delete_client(_inject_storage):
@pytest.fixture
def title_client(_inject_storage):
# Build the lifted refresh/set-title handlers the same way server.py
# wires the interactive bundle — same SessionEndpointConfig
# (manager_lookup + _interactive_tenant_check) so the tests exercise
# the production resolution path (mgr fast-path → storage ownership).
mock_mgr = MagicMock()
cfg = SessionEndpointConfig(
permission_gate=None,
manager_lookup=lambda _r: (mock_mgr, None),
tenant_check=_interactive_tenant_check,
not_found_label="Workstream not found",
audit_action_prefix="workstream",
)
app = Starlette(
routes=[
Mount(
@@ -133,12 +120,12 @@ def title_client(_inject_storage):
routes=[
Route(
"/api/workstreams/{ws_id}/title",
make_set_title_handler(cfg),
set_workstream_title,
methods=["POST"],
),
Route(
"/api/workstreams/{ws_id}/refresh-title",
make_refresh_title_handler(cfg),
refresh_workstream_title,
methods=["POST"],
),
],
@@ -146,6 +133,7 @@ def title_client(_inject_storage):
],
middleware=[Middleware(_InjectAuthMiddleware)],
)
mock_mgr = MagicMock()
app.state.workstreams = mock_mgr
return TestClient(app), mock_mgr
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.6.9"
__version__ = "1.7.0a2"
+2 -2
View File
@@ -1022,8 +1022,8 @@ def main() -> None:
"--judge-timeout",
dest="judge_timeout",
type=float,
default=120.0,
help="LLM judge timeout in seconds (default: 120)",
default=60.0,
help="LLM judge timeout in seconds (default: 60)",
)
judge_group.add_argument(
"--judge-confidence",
+1 -12
View File
@@ -94,11 +94,6 @@ class ClusterCollector:
self._nodes: dict[str, NodeSnapshot] = {}
self._running = False
self._threads: list[threading.Thread] = []
# Wakes the discovery loop out of its inter-scan sleep so ``stop()``
# can join it promptly instead of blocking up to ``discovery_interval``
# (a long interval would otherwise leave the thread sleeping past
# join's timeout — a leaked background thread).
self._discovery_wake = threading.Event()
# SSE fan-out to browser clients
self._listeners: list[queue.Queue[dict[str, Any]]] = []
@@ -140,9 +135,6 @@ class ClusterCollector:
def start(self) -> None:
"""Start background threads."""
self._running = True
# Clear the shutdown wake so a restarted collector (stop() set it) sleeps
# the full interval again instead of busy-spinning the discovery loop.
self._discovery_wake.clear()
# Subscribe to the ``services`` channel for reactive node discovery.
# NOTIFY-driven wake-ups bring new-node visibility from up-to-60 s
# (next discovery tick) down to ~500 ms on Postgres; the 60 s
@@ -169,7 +161,6 @@ class ClusterCollector:
its ``finally`` cleanup (cancel tasks, close AsyncClient).
"""
self._running = False
self._discovery_wake.set() # wake the discovery loop out of its sleep
if self._notify_unsubscribe is not None:
with contextlib.suppress(Exception):
self._notify_unsubscribe()
@@ -426,9 +417,7 @@ class ClusterCollector:
pass # already logged by storage layer
except Exception:
log.exception("Node discovery error")
# Interruptible inter-scan sleep — ``stop()`` sets the event to
# wake us immediately instead of blocking out the full interval.
self._discovery_wake.wait(self._discovery_interval)
time.sleep(self._discovery_interval)
def _discover_nodes(self) -> None:
"""Query the service registry and update the node map."""
+3 -32
View File
@@ -23,8 +23,6 @@ from turnstone.core.child_event_bus import ChildEventBus
from turnstone.core.child_source import ClusterChildSource
from turnstone.core.children_registry import ChildrenRegistry
from turnstone.core.log import get_logger
from turnstone.core.memory import get_workstream_display_name, get_workstream_display_names
from turnstone.core.storage import is_storage_initialized
from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamState
if TYPE_CHECKING:
@@ -39,28 +37,6 @@ if TYPE_CHECKING:
log = get_logger(__name__)
def _coord_display_name(ws: Workstream) -> str:
"""Resolve a coordinator's display name (``alias > title > name``).
``ws.name`` is the synthetic ``ws-xxxx`` placeholder; the persisted
auto-title (``update_workstream_title``) and user alias live only in
the DB. Seeding the collector with the resolved name means a
rehydrated coordinator shows its title in the live cluster tree
immediately, rather than reverting to ``ws-xxxx`` until a (for
coordinators, rarely-firing) ``on_rename`` event arrives.
Skips the DB read when storage isn't initialized: this runs on a
lifecycle-event path, and a display-name resolution must never trip
``get_storage``'s SQLite auto-init side effect (a stray
``.turnstone.db``) before the host has called ``init_storage`` (the
real cluster always does so at startup this only bites early /
test call paths). The placeholder ``ws.name`` is the right fallback.
"""
if not is_storage_initialized():
return ws.name
return get_workstream_display_name(ws.id) or ws.name
class CoordinatorAdapter:
"""Bridges SessionManager to the console's coordinator transport."""
@@ -156,7 +132,7 @@ class CoordinatorAdapter:
try:
self._collector.emit_console_ws_created(
ws.id,
name=_coord_display_name(ws),
name=ws.name,
user_id=ws.user_id,
kind=ws.kind.value,
state=ws.state.value,
@@ -490,16 +466,11 @@ class CoordinatorAdapter:
# creates happened before the collector was wired up and their
# rows never showed on the snapshot. (Coord-specific — interactive
# has no analogous pseudo-node.)
coords = mgr.list_all()
# One round-trip for every coordinator's display name instead of a
# per-``ws`` ``_coord_display_name`` lookup (N+1); cold path, but
# the bulk helper is right there.
seed_names = get_workstream_display_names([ws.id for ws in coords])
for ws in coords:
for ws in mgr.list_all():
try:
collector.emit_console_ws_created(
ws.id,
name=seed_names.get(ws.id) or ws.name,
name=ws.name,
user_id=ws.user_id or "",
kind=WorkstreamKind.COORDINATOR.value,
state=ws.state.value,
+37 -82
View File
@@ -55,8 +55,6 @@ from turnstone.core.auth import (
jwt_version_slot,
require_permission,
)
from turnstone.core.deadline import DeadlineExceededError, run_with_deadline
from turnstone.core.memory import get_workstream_display_names
from turnstone.core.rendezvous import NoAvailableNodeError
from turnstone.core.session_replay import session_replay_preamble
from turnstone.core.session_routes import (
@@ -76,11 +74,9 @@ from turnstone.core.session_routes import (
make_history_handler,
make_list_handler,
make_open_handler,
make_refresh_title_handler,
make_retry_handler,
make_rewind_handler,
make_send_handler,
make_set_title_handler,
make_unified_saved_handler,
register_coord_verbs,
register_session_routes,
@@ -856,14 +852,6 @@ def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
In-memory wins on ws_id conflict so live state stays authoritative
for active sessions.
Display name resolves ``alias > title > name`` from the persisted
row for BOTH lanes. ``ws.name`` on the in-memory Workstream is the
synthetic ``ws-xxxx`` placeholder; the LLM auto-title
(``update_workstream_title``) and the user alias
(``set_workstream_alias``) live only in the DB, so without the
persisted lookup the live lane would show ``ws-xxxx`` and the
auto-title would never survive a dashboard refresh.
Trusted-team visibility (post-#400): the cluster dashboard shows
every coordinator regardless of caller identity; ``user_id`` is
surfaced on each row as display metadata.
@@ -881,61 +869,6 @@ def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
val = getattr(sess, name, "") if sess else ""
return val if isinstance(val, str) else ""
# Persisted coordinator rows serve two purposes: (1) surface
# closed / error / deleted coordinators the manager has already
# evicted from ``self._workstreams``, and (2) supply the persisted
# display name (``alias > title > name``) for the LIVE coordinators
# too — ``ws.name`` is the synthetic placeholder. Cluster-wide
# (trusted-team visibility). Indexed by ws_id so both lanes resolve
# the same way.
storage = getattr(request.app.state, "auth_storage", None)
persisted: list[Any] = []
if storage is not None:
try:
persisted = storage.list_workstreams(
kind=WorkstreamKind.COORDINATOR,
user_id=None,
limit=200,
)
except Exception:
log.debug("cluster_workstreams.coord_persisted_failed", exc_info=True)
persisted = []
# SQLAlchemy Row — access via _mapping so future SELECT reorders /
# new columns don't silently corrupt the projection (per the
# storage-protocol guidance on list_workstreams). Test doubles must
# expose the same ._mapping attribute.
meta: dict[str, Any] = {}
for row in persisted:
m = row._mapping
rid = m.get("ws_id") or ""
if rid:
meta[rid] = m
# Live coordinators resolve their display name through the bulk
# helper keyed on their EXACT ids (one round-trip, no row cap) rather
# than the ``limit=200`` ``meta`` map: a live coord that has dropped
# below the 200-row ``updated DESC`` window would otherwise revert to
# its synthetic ``ws.name``. Closed/evicted rows (the persisted lane
# below) already carry alias/title in their own ``_mapping``.
live_display = get_workstream_display_names([ws.id for ws in wss]) if wss else {}
def _display_name(ws_id: str, fallback: str) -> str:
m = meta.get(ws_id)
if m is None:
return fallback
return m.get("alias") or m.get("title") or m.get("name") or fallback
def _title(ws_id: str) -> str:
# Best-effort: the secondary ``title`` field is sourced from the
# ``limit=200`` ``meta`` map, so a live coord outside that window
# reports ``""`` here. The user-visible ``name`` stays correct
# (resolved via the uncapped ``live_display`` above, and the UI
# renders ``title || name``); the empty title is harmless and the
# window is unreachable in practice (live coords are bounded by
# ``max_active`` and sort to the top of ``updated DESC``).
m = meta.get(ws_id)
return str(m.get("title") or "") if m is not None else ""
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for ws in wss:
@@ -943,9 +876,9 @@ def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
rows.append(
{
"id": ws.id,
"name": live_display.get(ws.id) or ws.name,
"name": ws.name,
"state": ws.state.value,
"title": _title(ws.id),
"title": "",
"node": "console",
"server_url": "",
"model": _str_sess_attr(sess, "model"),
@@ -962,7 +895,30 @@ def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
)
seen.add(ws.id)
# Second lane — persisted coordinator rows, used to surface
# closed / error / deleted coordinators the manager has already
# evicted from ``self._workstreams``. Cluster-wide (trusted-team
# visibility).
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return rows
try:
persisted = storage.list_workstreams(
kind=WorkstreamKind.COORDINATOR,
user_id=None,
limit=200,
)
except Exception:
log.debug("cluster_workstreams.coord_persisted_failed", exc_info=True)
return rows
for row in persisted:
# SQLAlchemy Row — access via _mapping so future SELECT reorders
# / new columns don't silently corrupt the projection (per the
# storage-protocol guidance on list_workstreams). Test doubles
# must expose the same ._mapping attribute; positional indexing
# was removed because it hard-coded column offsets that drift
# with migrations.
m = row._mapping
row_id = m.get("ws_id") or ""
if not row_id or row_id in seen:
@@ -971,9 +927,9 @@ def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
rows.append(
{
"id": row_id,
"name": _display_name(row_id, f"coord-{row_id[:4]}"),
"name": m.get("name") or f"coord-{row_id[:4]}",
"state": str(m.get("state") or "idle"),
"title": _title(row_id),
"title": "",
"node": "console",
"server_url": "",
"model": "",
@@ -11574,15 +11530,16 @@ def _validate_regex_pattern(pattern: str, flags: int = 0) -> str | None:
compiled.search(s)
try:
# Daemon worker: a catastrophically-backtracking regex must be
# abandonable without pinning a non-daemon thread that would hang
# interpreter exit (a ThreadPoolExecutor worker is joined at exit).
# Budget is generous — a legitimately complex pattern can take a second
# or two on the probe strings; only exponential blowup (which sails past
# any few-second bound) should trip the catastrophic-backtracking guard.
run_with_deadline(_probe, timeout=3.0, poll=0.1, thread_name="regex-redos-probe")
except DeadlineExceededError:
return "Regex appears to have catastrophic backtracking"
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeout
pool = ThreadPoolExecutor(max_workers=1)
try:
pool.submit(_probe).result(timeout=0.5)
except FuturesTimeout:
return "Regex appears to have catastrophic backtracking"
finally:
pool.shutdown(wait=False, cancel_futures=True)
except Exception:
return "Regex caused an error during test"
return None
@@ -13022,8 +12979,6 @@ def create_app(
audit_emit=_audit_close_coordinator,
supports_close_reason=False,
),
refresh_title=make_refresh_title_handler(coord_endpoint_config), # lifted: shared body
set_title=make_set_title_handler(coord_endpoint_config), # lifted: shared body
send=make_send_handler(coord_endpoint_config), # lifted: shared body (P1.5)
dequeue=make_dequeue_handler(coord_endpoint_config), # lifted: shared body
approve=make_approve_handler(coord_endpoint_config), # lifted: shared body
+18 -197
View File
@@ -15,16 +15,11 @@ backend is surfaced as a typed error the endpoint maps to 503 / 502.
from __future__ import annotations
import subprocess
import threading
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import Any
from turnstone.core.log import get_logger
from turnstone.core.server_compat import merge_server_compat
if TYPE_CHECKING:
from collections.abc import Iterator
log = get_logger(__name__)
@@ -88,22 +83,6 @@ _OMNI_STT_PROMPT = (
"seven, and write 3 instead of three"
)
# Bound the omni STT decode. Gemma caps audio at 30 s and a 30 s transcript is
# well under this, so the cap only catches a pathological runaway — it never
# truncates a real transcript.
_OMNI_STT_MAX_TOKENS = 1024
# Hard limit on the ffmpeg transcode subprocess (seconds).
_FFMPEG_TIMEOUT_S = 30
# Cap the decoded audio duration so a crafted clip can't expand into an
# unbounded decode (the upload itself is already size-capped at the endpoint).
_MAX_AUDIO_SECONDS = 300
# Per-request timeout for the streaming STT chat call — bounds a hung backend
# (the whole transcription is ~1 s; this only catches a stalled stream).
_OMNI_STT_TIMEOUT_S = 60
@dataclass(frozen=True)
class TranscriptionResult:
@@ -206,115 +185,30 @@ def _serves_transcription_endpoint(cfg: Any, model: str) -> bool:
return _infer_audio_capability(model, "stt")
def _to_wav_16k_mono(data: bytes) -> bytes:
"""Decode any ffmpeg-readable audio container to 16 kHz mono PCM WAV.
Browsers record webm/opus (or ogg/mp4); the omni chat lane vLLM in
particular only decodes wav/mp3 and sniffs the bytes, so the raw upload is
rejected as an "Invalid or unsupported audio file". ffmpeg reads the
container from the byte stream (no reliance on the filename) and resamples to
the 16 kHz mono PCM the model documents. Raises :class:`AudioBackendError`
(the endpoint maps it to 502) if ffmpeg is missing or the bytes don't decode.
"""
# ffmpeg reads only the piped bytes (-protocol_whitelist pipe) so a crafted
# container can't open file:/http: references (SSRF / local file read); -vn
# drops video streams and -t bounds the decode against a decompression bomb.
try:
proc = subprocess.run(
[
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-protocol_whitelist",
"pipe",
"-i",
"pipe:0",
"-vn",
"-t",
str(_MAX_AUDIO_SECONDS),
"-ac",
"1",
"-ar",
"16000",
"-f",
"wav",
"pipe:1",
],
input=data,
capture_output=True,
timeout=_FFMPEG_TIMEOUT_S,
)
except FileNotFoundError as exc:
raise AudioBackendError("ffmpeg is not installed; cannot transcode audio") from exc
except subprocess.TimeoutExpired as exc:
raise AudioBackendError("Audio transcode timed out") from exc
if proc.returncode != 0 or not proc.stdout:
detail = proc.stderr.decode("utf-8", "replace").strip()
raise AudioBackendError(f"Audio transcode failed: {detail[-200:] or 'no output'}")
return proc.stdout
def _omni_chat_extra_body(cfg: Any) -> dict[str, Any]:
"""Build the chat ``extra_body`` for an omni STT call.
The STT path calls the raw client, so it bypasses the provider's request
shaping. Reuse ``merge_server_compat`` to forward any operator-stored
``server_compat["extra_body"]``, then force **thinking OFF** via the model's
own ``thinking_param``: transcription needs no reasoning, and leaving it on
multiplies latency ~10x and (on some chat templates) empties the content.
The override is applied last so it wins over any operator thinking flag.
"""
server_compat = getattr(cfg, "server_compat", None)
extra = merge_server_compat(None, server_compat) if isinstance(server_compat, dict) else {}
caps = getattr(cfg, "capabilities", None) or {}
thinking_param = caps.get("thinking_param")
if thinking_param and caps.get("thinking_mode") in ("manual", "adaptive"):
extra.setdefault("chat_template_kwargs", {})[thinking_param] = False
return extra
def _omni_chat_messages(prompt: str, audio_b64: str) -> list[dict[str, Any]]:
"""The single user turn for an omni STT chat call: the prompt precedes the
audio part the order Gemma documents for transcription."""
return [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}},
],
}
]
def _transcribe_via_chat(
client: Any,
model: str,
data: bytes,
prompt: str,
*,
extra_body: dict[str, Any] | None = None,
max_tokens: int = _OMNI_STT_MAX_TOKENS,
) -> str:
def _transcribe_via_chat(client: Any, model: str, data: bytes, filename: str, prompt: str) -> str:
"""Transcribe by handing the clip to an omni *chat* model as ``input_audio``.
For models that accept audio in chat (``supports_audio_input``) but don't
serve ``/audio/transcriptions``. The clip is transcoded to 16 kHz mono WAV
first (browsers record webm/opus, which the chat lane can't decode). The
instruction ``prompt`` precedes the audio part the order Gemma documents
for transcription and ``extra_body`` carries the thinking-off / server
compat params the raw-client path would otherwise skip.
serve ``/audio/transcriptions``. The instruction ``prompt`` steers the model
to emit only the transcript. The audio format is taken from the upload's
filename extension (the same shape the attachment wire path uses).
"""
import base64
wav = _to_wav_16k_mono(data)
audio_b64 = base64.b64encode(wav).decode("ascii")
name = filename or "speech.webm"
fmt = name.rsplit(".", 1)[-1].lower() if "." in name else "wav"
audio_b64 = base64.b64encode(data).decode("ascii")
resp = client.chat.completions.create(
model=model,
messages=_omni_chat_messages(prompt, audio_b64),
max_tokens=max_tokens,
extra_body=extra_body or None,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": fmt}},
],
}
],
)
choices = getattr(resp, "choices", None) or []
if not choices:
@@ -367,86 +261,13 @@ def transcribe(
transcript = (getattr(resp, "text", "") or "").strip()
else:
transcript = _transcribe_via_chat(
client,
model,
data,
prompt or _OMNI_STT_PROMPT,
extra_body=_omni_chat_extra_body(cfg),
client, model, data, filename, prompt or _OMNI_STT_PROMPT
)
except AudioBackendError:
# Transcode errors already carry an actionable message — keep it.
raise
except Exception as exc:
raise AudioBackendError(f"Transcription backend failed: {exc}") from exc
return TranscriptionResult(transcript=transcript, model_alias=alias, model=model)
def _iter_stream_deltas(stream: Any) -> Iterator[str]:
"""Yield non-empty content deltas from an OpenAI streaming chat response.
Owns the stream's lifecycle: exhausting or closing this generator releases
the underlying HTTP connection, so an abandoned stream can't leak it.
"""
try:
for chunk in stream:
choices = getattr(chunk, "choices", None) or []
if not choices:
continue
delta = getattr(choices[0].delta, "content", None)
if delta:
yield delta
finally:
close = getattr(stream, "close", None)
if callable(close):
close()
def transcribe_stream(*, registry: Any, alias: str, data: bytes, prompt: str = "") -> Iterator[str]:
"""Stream transcript content deltas for the STT role alias.
Resolve, transcode, and opening the streaming-chat request all run eagerly
(before the returned generator yields its first delta) so the caller can
surface a clean 503 / 502; only the token iteration is deferred. A
whisper-style endpoint alias has no chat stream, so it emits the whole
transcript as a single chunk.
"""
try:
client, model, cfg = registry.resolve(alias)
except Exception as exc: # unknown/removed alias
raise AudioUnavailableError(f"STT model alias {alias!r} is not available") from exc
if not _provider_carries_audio(cfg):
raise AudioUnavailableError(
f"STT model alias {alias!r} (provider {getattr(cfg, 'provider', 'unknown')!r}) "
"can't transcribe audio — audio roles require an OpenAI-compatible provider."
)
if _serves_transcription_endpoint(cfg, model):
# Whisper-style endpoint: no chat stream — emit the whole transcript once.
text = transcribe(
registry=registry, alias=alias, data=data, filename="speech.webm", prompt=prompt
).transcript
return iter([text] if text else [])
caps = getattr(cfg, "capabilities", None) or {}
if not caps.get("supports_audio_input"):
raise AudioUnavailableError(f"STT model alias {alias!r} cannot transcribe audio")
import base64
wav = _to_wav_16k_mono(data)
audio_b64 = base64.b64encode(wav).decode("ascii")
try:
stream = client.chat.completions.create(
model=model,
messages=_omni_chat_messages(prompt or _OMNI_STT_PROMPT, audio_b64),
max_tokens=_OMNI_STT_MAX_TOKENS,
extra_body=_omni_chat_extra_body(cfg) or None,
stream=True,
timeout=_OMNI_STT_TIMEOUT_S,
)
except Exception as exc:
raise AudioBackendError(f"Transcription backend failed: {exc}") from exc
return _iter_stream_deltas(stream)
# -- transcript memoization (no-native-audio wire fallback) -------------------
# Caching an STT result is an audio-domain concern, so it lives here next to
# ``transcribe``. The wire resolver re-materializes every attachment on every
-94
View File
@@ -1,94 +0,0 @@
"""Run a blocking call under a wall-clock deadline on a daemon thread.
The motivating constraint comes from the judges (:mod:`turnstone.core.judge`,
:mod:`turnstone.core.output_guard_judge`): an upstream LLM call must be
*abandonable* the instant its timeout or cancel fires, without the abandoned
call being able to block process or interpreter exit.
A :class:`~concurrent.futures.ThreadPoolExecutor` worker is **non-daemon**, and
``concurrent.futures`` joins every executor worker from an ``atexit`` hook
(``_python_exit``) regardless of ``shutdown(wait=False)``. So an upstream call
wedged with no socket timeout hangs interpreter shutdown forever which is
exactly how a single slow judge call can deadlock a whole test run at exit.
A **daemon** worker is never joined at exit, so abandoning one is always safe:
the call keeps running until it returns or the process dies, whichever comes
first, and never pins shutdown.
"""
from __future__ import annotations
import queue
import threading
import time
from typing import TYPE_CHECKING, TypeVar
if TYPE_CHECKING:
from collections.abc import Callable
_T = TypeVar("_T")
class DeadlineExceededError(Exception):
"""The call did not complete before its wall-clock deadline."""
class DeadlineCancelledError(Exception):
"""The cancel event fired before the call completed."""
def run_with_deadline(
fn: Callable[[], _T],
*,
timeout: float,
cancel_event: threading.Event | None = None,
poll: float = 1.0,
thread_name: str = "deadline-worker",
) -> _T:
"""Run ``fn()`` on a daemon thread, bounded by ``timeout``/``cancel_event``.
Returns ``fn()``'s result, or re-raises whatever ``fn`` raised. Raises
:class:`DeadlineExceededError` if ``timeout`` seconds elapse first, or
:class:`DeadlineCancelledError` if ``cancel_event`` fires first. On either
abort the worker thread is abandoned; being a daemon it cannot block
process or interpreter exit.
``poll`` bounds how often ``cancel_event`` is checked (and thus the worst-
case latency from a cancel to this function returning).
"""
box: queue.Queue[tuple[bool, object]] = queue.Queue(maxsize=1)
def _runner() -> None:
try:
box.put((True, fn()))
except BaseException as exc: # noqa: BLE001 - relayed to the caller verbatim
box.put((False, exc))
threading.Thread(target=_runner, name=thread_name, daemon=True).start()
deadline = time.monotonic() + timeout
while True:
# Prefer a result that has already arrived over a deadline or cancel
# firing in the same scheduling window — otherwise a completed call
# could be reported as a spurious timeout/cancel under jitter.
try:
ok, payload = box.get_nowait()
except queue.Empty:
pass
else:
if ok:
return payload # type: ignore[return-value] # ok=True ⇒ payload is _T
raise payload # type: ignore[misc] # ok=False ⇒ payload is the raised exc
if cancel_event is not None and cancel_event.is_set():
raise DeadlineCancelledError
remaining = deadline - time.monotonic()
if remaining <= 0:
raise DeadlineExceededError
try:
ok, payload = box.get(timeout=min(remaining, poll))
except queue.Empty:
continue
if ok:
return payload # type: ignore[return-value]
raise payload # type: ignore[misc]
+50 -33
View File
@@ -15,16 +15,11 @@ import re
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any
from turnstone.core.deadline import (
DeadlineCancelledError,
DeadlineExceededError,
run_with_deadline,
)
from turnstone.core.log import get_logger
if TYPE_CHECKING:
@@ -81,8 +76,8 @@ class JudgeConfig:
"""Configuration for the intent validation judge.
The *timeout* value applies **per turn**, not as a total budget across
all turns. With the default of 120 s and a maximum of 5 turns, a
single tool-call evaluation can take up to 600 s in the worst case
all turns. With the default of 60 s and a maximum of 5 turns, a
single tool-call evaluation can take up to 300 s in the worst case
(e.g. a multi-turn tool-use exchange with a slow local model).
"""
@@ -91,13 +86,13 @@ class JudgeConfig:
smart_approvals: bool = False # auto-approve high-confidence "approve" LLM verdicts
confidence_threshold: float = 0.95 # Smart Approvals auto-approve bar (recommendation=approve)
max_context_ratio: float = 0.5
timeout: float = 120.0 # per-turn timeout in seconds (see class docstring)
timeout: float = 60.0 # per-turn timeout in seconds (see class docstring)
read_only_tools: bool = True
output_guard: bool = True
output_guard_budget_seconds: float = 30.0 # wall-clock budget for output_guard regex scan
output_guard_llm: bool = False # enable LLM stage on tool output (issue #560 mitigation #1)
output_guard_model: str = "" # alias for the LLM stage; empty = inherit session model
output_guard_llm_timeout: float = 60.0 # wall-clock budget for the LLM stage
output_guard_llm_timeout: float = 30.0 # wall-clock budget for the LLM stage
redact_secrets: bool = True
# True = the approval gate's resolution aborts remaining evaluations
# (saves inference; undone items degrade to ``llm_fallback`` verdicts
@@ -886,6 +881,10 @@ If you used read_file to check a target, cite what you found."""
# ---------------------------------------------------------------------------
class _ExecutorPoisonedError(Exception):
"""Raised when a timeout leaves the executor's worker thread stuck."""
class IntentJudge:
"""Session-scoped LLM judge for intent validation.
@@ -1055,6 +1054,7 @@ class IntentJudge:
verdicts are delivered.
"""
client = self._create_client()
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
try:
for idx, (item, h_verdict) in enumerate(zip(items, heuristic_verdicts, strict=True)):
if cancel_event and cancel_event.is_set():
@@ -1071,6 +1071,7 @@ class IntentJudge:
item,
messages,
cancel_event,
executor,
client,
)
if llm_verdict:
@@ -1115,6 +1116,17 @@ class IntentJudge:
"judge cancelled before evaluating this call",
)
return
except _ExecutorPoisonedError:
executor.shutdown(wait=False, cancel_futures=True)
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
# Deliver a fallback for the interrupted item so every
# call still gets exactly one verdict. Smart Approvals
# waits on the full set before gating; a silently-
# skipped item would otherwise block that wait until
# its timeout (and the advisory UI would miss a chip).
self._deliver_fallbacks(
[item], [h_verdict], callback, "judge executor restarted"
)
except Exception:
log.exception(
"Judge evaluation failed for %s",
@@ -1122,6 +1134,7 @@ class IntentJudge:
)
self._deliver_fallbacks([item], [h_verdict], callback, "judge evaluation error")
finally:
executor.shutdown(wait=False, cancel_futures=True)
try:
if hasattr(client, "close"):
client.close()
@@ -1159,6 +1172,7 @@ class IntentJudge:
item: dict[str, Any],
messages: list[dict[str, Any]],
cancel_event: threading.Event | None,
executor: ThreadPoolExecutor,
client: Any,
) -> IntentVerdict | None:
"""Run LLM judge for a single tool call. Returns verdict or None."""
@@ -1223,29 +1237,32 @@ class IntentJudge:
# models aren't penalised for slow earlier turns.
per_call_timeout = max(self._config.timeout, 5.0) # at least 5s
try:
# Each turn runs on its own daemon worker (1s cancel polling).
# A timeout or cancel abandons the call without pinning a
# non-daemon thread that would block interpreter exit — the old
# single-slot ThreadPoolExecutor left a stuck worker that
# poisoned the pool, which is why the restart dance existed.
result = run_with_deadline(
partial(
self._provider.create_completion,
client=client,
model=self._model,
messages=judge_messages,
tools=None if is_last_turn else tools,
max_tokens=2048,
temperature=0.0,
reasoning_effort="medium",
),
timeout=per_call_timeout,
cancel_event=cancel_event,
thread_name="judge-api",
future = executor.submit(
self._provider.create_completion,
client=client,
model=self._model,
messages=judge_messages,
tools=None if is_last_turn else tools,
max_tokens=2048,
temperature=0.0,
reasoning_effort="medium",
)
except DeadlineCancelledError:
return None
except DeadlineExceededError:
# Poll in 1s increments so we notice cancellation promptly
# instead of blocking for the full per_call_timeout.
deadline = time.monotonic() + per_call_timeout
while True:
remaining = deadline - time.monotonic()
if cancel_event and cancel_event.is_set():
future.cancel()
return None
if remaining <= 0:
raise TimeoutError
try:
result = future.result(timeout=min(remaining, 1.0))
break
except TimeoutError:
pass # loop back to check remaining/cancel
except TimeoutError:
log.info("judge.turn.timeout", turn=turn + 1, timeout=per_call_timeout)
# Safety net: if we have a partial result from a previous turn,
# try to parse a verdict from it before giving up.
@@ -1260,7 +1277,7 @@ class IntentJudge:
if verdict:
log.info("judge.verdict.from_partial", turn=turn + 1)
return verdict
return None
raise _ExecutorPoisonedError from None
except Exception as e:
log.info("judge.turn.failed", turn=turn + 1, error=str(e))
return None
+40 -38
View File
@@ -12,13 +12,13 @@ Design:
a static tool result doesn't benefit from multi-turn — the text is
already in hand.
- JSON-in-content verdict. 4-strategy parser inlined from
:meth:`IntentJudge._parse_verdict`.
- Wall-clock deadline via :func:`turnstone.core.deadline.run_with_deadline`,
which runs the call on a *daemon* worker and polls the cancel event each
second. A timeout or cancel abandons the call rather than waiting it out,
and the daemon worker can never block process or interpreter exit unlike
a ``ThreadPoolExecutor`` worker, which ``concurrent.futures`` joins from an
``atexit`` hook regardless of ``shutdown(wait=False)``.
:class:`IntentJudge` (``judge.py:1603-1659``).
- ``ThreadPoolExecutor`` + ``future.result(timeout=)`` with 1 s
cancel-event polling. The executor is owned explicitly with
``shutdown(wait=False, cancel_futures=True)`` so a timeout or
cancellation returns promptly even if the worker thread is still
blocked on the upstream LLM call. This mirrors
:meth:`IntentJudge._run_judge`'s pattern at ``judge.py:1117-1118``.
- HTTP client is lazy-init + reused across evaluations on a single
judge instance. Session-side model swaps drop the entire
:class:`OutputGuardJudge` (``session.py:1733``/``:2136``), which
@@ -39,15 +39,11 @@ import json
import re
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from turnstone.core import fence
from turnstone.core.deadline import (
DeadlineCancelledError,
DeadlineExceededError,
run_with_deadline,
)
from turnstone.core.log import get_logger
if TYPE_CHECKING:
@@ -374,10 +370,9 @@ class OutputGuardJudge:
by :meth:`_user_prompt`. Callers that don't have a particular
field leave it at its default the prompt skips empty sections.
Timeout enforcement is real wall-clock: the upstream call runs on a
daemon worker via :func:`~turnstone.core.deadline.run_with_deadline`
and is abandoned on the timeout / cancel path, so a hung upstream LLM
call neither blocks return nor pins interpreter exit.
Timeout enforcement is real wall-clock: the executor is shut
down with ``wait=False, cancel_futures=True`` on the timeout /
cancel path, so a hung upstream LLM call does not block return.
"""
if not output:
return OutputJudgeVerdict(
@@ -412,16 +407,15 @@ class OutputGuardJudge:
verdict_id, call_id, start, f"client_create_failed: {type(e).__name__}"
)
# Run the upstream call on a *daemon* worker bounded by a real
# wall-clock deadline: a timeout or cancel abandons the call instead of
# waiting it out, and because the worker is a daemon an abandoned call
# can never block process or interpreter exit. (A ThreadPoolExecutor
# worker is non-daemon, and concurrent.futures joins it from an atexit
# hook regardless of shutdown(wait=False) — so a wedged upstream call
# would otherwise hang shutdown.)
# Explicit executor lifetime — the `with ... as ex:` form's
# implicit shutdown(wait=True) would block return until the
# upstream call completed, defeating the wall-clock timeout.
# Mirror IntentJudge's pattern at judge.py:1117-1118.
ex = ThreadPoolExecutor(max_workers=1, thread_name_prefix="output-guard-judge")
try:
result = run_with_deadline(
lambda: self._provider.create_completion(
try:
future = ex.submit(
self._provider.create_completion,
client=client,
model=self._model,
messages=judge_messages,
@@ -429,19 +423,27 @@ class OutputGuardJudge:
max_tokens=512,
temperature=0.0,
reasoning_effort="low",
),
timeout=timeout,
cancel_event=cancel_event,
thread_name="output-guard-judge",
)
except DeadlineCancelledError:
return self._error_verdict(verdict_id, call_id, start, "cancelled")
except DeadlineExceededError:
return self._error_verdict(verdict_id, call_id, start, "timeout")
except Exception as e:
return self._error_verdict(
verdict_id, call_id, start, f"provider_error: {type(e).__name__}"
)
)
deadline = time.monotonic() + timeout
while True:
if cancel_event is not None and cancel_event.is_set():
future.cancel()
return self._error_verdict(verdict_id, call_id, start, "cancelled")
remaining = deadline - time.monotonic()
if remaining <= 0:
future.cancel()
return self._error_verdict(verdict_id, call_id, start, "timeout")
try:
result = future.result(timeout=min(remaining, 1.0))
break
except TimeoutError:
continue
except Exception as e:
return self._error_verdict(
verdict_id, call_id, start, f"provider_error: {type(e).__name__}"
)
finally:
ex.shutdown(wait=False, cancel_futures=True)
content = (getattr(result, "content", "") or "").strip()
if not content:
+9 -6
View File
@@ -14,12 +14,10 @@ request shaping. This module separates three concerns:
Stored under ``server_compat`` because it's an endpoint property,
not a model property.
3. **Server workarounds** ``extra_body`` overrides like llama.cpp's
``reasoning_format`` are properties of the *server*, not the model.
These stay in ``server_compat`` and get merged into the request's
``extra_body`` at call time. Reserve these for stable server config:
bug-workaround flags for fast-moving open models go stale the moment
the upstream bug is fixed, so we don't carry them speculatively.
3. **Server workarounds** ``extra_body`` overrides like
``skip_special_tokens=false`` are properties of the *server* (vLLM
bug workaround). These stay in ``server_compat`` and get merged
into the request's ``extra_body`` at call time.
Profiles are *suggestions* only. The admin UI auto-fills them on
Detect; the operator has final say, and the stored DB config is what
@@ -47,6 +45,11 @@ _PROFILES: dict[str, dict[str, Any]] = {
},
"server_compat": {
"server_type": "vllm",
# Workaround: vLLM strips special tokens before the Gemma4
# reasoning parser sees them. skip_special_tokens=false
# preserves <|channel> / <channel|> markers so reasoning
# content is extracted correctly.
"extra_body": {"skip_special_tokens": False},
},
},
"vllm-qwen-thinking": {
+6 -26
View File
@@ -2425,15 +2425,10 @@ class ChatSession:
ws_id = self._ws_id # Capture before async work
log.info("ws.title.gen_start", ws_id=ws_id[:8])
try:
# Gather first user message and first assistant reply.
# Snapshot ``self.messages`` (C-level atomic copy under the
# GIL): this runs in a background thread that may now fire
# while the main ``send`` loop is still streaming and
# appending turns, so iterating the live list directly could
# raise "list changed size during iteration".
# Gather first user message and first assistant reply
user_msg = ""
asst_msg = ""
for m in list(self.messages):
for m in self.messages:
content = m.text # joins text blocks; multipart attachments contribute none
if m.role is Role.USER and not user_msg:
user_msg = content[:300]
@@ -4155,25 +4150,6 @@ class ChatSession:
# legacy per-message ``_reminders`` side-channel splice.
self._emit_pending_user_nudges()
# Auto-title from the opening user message — fire NOW rather than
# waiting for the assistant's final tool-call-free turn. The old
# trigger sat in the ``not tool_calls`` branch of the loop below;
# coordinators spend nearly every turn in tool calls and may never
# reach that terminal text turn, so the title almost never
# generated for them. Gate on a real user message: synthetic wake
# sends carry no content and ``_generate_title`` would no-op on the
# empty/attachment-only case anyway (it needs first-user-message
# text). Concurrency: this background thread runs alongside the
# streaming turn started below, but safely — it snapshots
# ``self.messages`` for iteration, and the only UI it touches is
# ``on_aux_usage`` (storage/metrics, no ``_ws_lock`` state) and
# ``on_rename`` (queue/locked fan-out), both documented
# auxiliary-thread-safe on ``SessionUIBase``; the provider + client
# handle concurrent requests (the same path ``task_agent`` uses).
if not self._title_generated and user_input.strip() and not from_wake:
self._title_generated = True
threading.Thread(target=self._generate_title, daemon=True).start()
# A fresh session composed its system prefix at __init__ with an empty
# history, so memory selection fell back to recency (no query, no rerank).
# Recompose once the first real user message exists so the opening turn
@@ -4316,6 +4292,10 @@ class ChatSession:
self._compact_messages(auto=True)
# Update status bar with post-compaction token counts
self._print_status_line()
# Auto-title session after first exchange
if not self._title_generated:
self._title_generated = True
threading.Thread(target=self._generate_title, daemon=True).start()
# Flush any queued messages that weren't injected
# (no tool calls → no advisory seam to inject at).
# If anything drained, the model hasn't seen those
+3 -118
View File
@@ -493,8 +493,9 @@ class SharedSessionVerbHandlers:
"""Bundle of HTTP handler callables for verbs both kinds expose.
All handlers are optional; ``None`` skips that route. One bundle
describes either kind coord omits ``delete``; interactive
populates every interaction verb post-Stage-2.
describes either kind coord omits ``delete`` / ``refresh_title``
/ ``set_title`` / attachments; interactive populates every
interaction verb post-Stage-2.
"""
# Listing
@@ -971,122 +972,6 @@ def make_close_handler(
return close
def make_refresh_title_handler(cfg: SessionEndpointConfig) -> Handler:
"""Lifted body for ``POST {prefix}/{ws_id}/refresh-title``.
Regenerates the workstream title via a background LLM call
(:meth:`ChatSession.request_title_refresh`). Both kinds share the
auth mgr ws-lookup request sequence; the session must be live
in memory (``mgr.get``, not ``open``) since the refresh runs on the
loaded :class:`ChatSession`. The current display name is passed so
the generator is steered toward a *different* title on a manual
refresh.
"""
async def refresh_title(request: Request) -> Response:
import asyncio
from turnstone.core.memory import get_workstream_display_name
if cfg.permission_gate is not None:
err = cfg.permission_gate(request)
if err is not None:
return err
mgr_opt, err503 = cfg.manager_lookup(request)
if err503 is not None:
return err503
# See ``make_approve_handler`` for the cast rationale.
mgr = cast("SessionManager", mgr_opt)
ws_id = request.path_params.get("ws_id", "")
if cfg.tenant_check is not None:
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
if err_tenant is not None:
return err_tenant
ws = mgr.get(ws_id)
if ws is None or ws.session is None:
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
current_title = await asyncio.to_thread(get_workstream_display_name, ws_id) or ""
ws.session.request_title_refresh(current_title)
return JSONResponse({"status": "ok"})
return refresh_title
def make_set_title_handler(cfg: SessionEndpointConfig) -> Handler:
"""Lifted body for ``POST {prefix}/{ws_id}/title``.
Sets a user-chosen title manually. Stored as the workstream *alias*
so it outranks the LLM auto-title in the display fallback chain
(``alias > title > name``). Both kinds share the auth validate
``set_workstream_alias`` ``on_rename`` sequence. Returns 409 when
the name collides with another workstream's alias.
Behavior matches the pre-lift interactive handler: the alias is set
against storage regardless of whether the session is loaded (so a
saved/closed workstream can still be renamed), and the live
``on_rename`` broadcast fires only when the session is in memory.
"""
async def set_title(request: Request) -> Response:
import asyncio
from turnstone.core.memory import set_workstream_alias
from turnstone.core.web_helpers import read_json_or_400
if cfg.permission_gate is not None:
err = cfg.permission_gate(request)
if err is not None:
return err
mgr_opt, err503 = cfg.manager_lookup(request)
if err503 is not None:
return err503
mgr = cast("SessionManager", mgr_opt)
ws_id = request.path_params.get("ws_id", "")
if not ws_id:
return JSONResponse({"error": "ws_id is required"}, status_code=400)
if cfg.tenant_check is not None:
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
if err_tenant is not None:
return err_tenant
# Resolve the workstream BEFORE writing the alias. ``set_workstream_alias``
# is a global, kind-unscoped UPDATE keyed on ``ws_id`` alone (it returns
# True even on a 0-row match), so a kind that has no ``tenant_check``
# storage gate (coord — the in-memory manager is its existence + kind
# authority) must 404 here, or an operator could rename a workstream this
# manager doesn't own (e.g. an interactive ws via the coord route) and a
# bogus id would silently 200. Interactive keeps ``tenant_check`` as its
# existence gate, so this stays skipped there and a non-loaded
# saved/closed ws still renames.
ws = mgr.get(ws_id)
if cfg.tenant_check is None and ws is None:
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
title = str(body.get("title", "")).strip()
if not title:
return JSONResponse({"error": "title is required"}, status_code=400)
title = title[:80]
if not await asyncio.to_thread(set_workstream_alias, ws_id, title):
return JSONResponse(
{"error": "That name is already used by another workstream"},
status_code=409,
)
if ws is not None and ws.session is not None and ws.session.ui is not None:
ws.session.ui.on_rename(title)
return JSONResponse({"status": "ok", "title": title})
return set_title
CancelAuditEmitter = Callable[
["Request", str, "Workstream", bool],
None,
+2 -12
View File
@@ -201,18 +201,8 @@ class SessionUIBase:
methods (and the approval blocking helpers that live on
subclasses); HTTP handlers drive ``_register_listener`` /
``_unregister_listener`` / ``resolve_approval`` from the event
loop. All shared state is guarded by ``_listeners_lock`` /
``_ws_lock`` or ``threading.Event`` primitives.
Two ``on_*`` methods are additionally safe to call from a
*concurrent* auxiliary thread (e.g. background title generation in
``ChatSession._generate_title``, or ``task_agent`` sub-agents), even
while the worker thread is mid-stream: :meth:`on_aux_usage` (a
storage ``usage_event`` write + thread-safe metric counters it
touches none of the ``_ws_lock``-guarded inflight state
:meth:`on_status`/token writers mutate) and :meth:`on_rename` (a
queue / locked fan-out). Keep those two free of unguarded
``_ws_*`` writes so the auxiliary-thread guarantee holds.
loop. All shared state is guarded by ``_listeners_lock`` or
``threading.Event`` primitives.
"""
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
+2 -2
View File
@@ -577,7 +577,7 @@ def _build_registry() -> dict[str, SettingDef]:
SettingDef(
"judge.timeout",
"float",
120.0,
60.0,
"Judge evaluation timeout in seconds",
"judge",
min_value=5.0,
@@ -641,7 +641,7 @@ def _build_registry() -> dict[str, SettingDef]:
SettingDef(
"judge.output_guard_llm_timeout",
"float",
60.0,
30.0,
"Wall-clock budget for the output-guard LLM judge call",
"judge",
min_value=1.0,
-2
View File
@@ -8,7 +8,6 @@ from turnstone.core.storage._registry import (
StorageUnavailableError,
get_storage,
init_storage,
is_storage_initialized,
reset_storage,
)
@@ -18,6 +17,5 @@ __all__ = [
"StorageUnavailableError",
"get_storage",
"init_storage",
"is_storage_initialized",
"reset_storage",
]
-6
View File
@@ -1052,12 +1052,6 @@ class PostgreSQLBackend:
workstreams.c.skill_id,
workstreams.c.skill_version,
workstreams.c.user_id,
# Appended after ``user_id`` so positional fallbacks in
# consumers (``_coord_children_row`` et al.) that index
# up to row[9] stay valid; ``_coordinator_rows`` reads
# these by name to surface the persisted display title.
workstreams.c.title,
workstreams.c.alias,
)
.order_by(workstreams.c.updated.desc())
.limit(limit)
+1 -3
View File
@@ -679,9 +679,7 @@ class StorageBackend(Protocol):
Returns a list of SQLAlchemy ``Row`` objects. **Prefer dict access
via ``row._mapping[<col>]``**; positional indexing is brittle against
future SELECT reorders and against new columns appearing in the
tail (the select currently ends with ``user_id, title, alias``
``title``/``alias`` were appended after ``user_id`` so existing
positional fallbacks that index up to row[9] stay valid).
tail (the select currently ends with ``user_id``).
"""
...
-13
View File
@@ -124,19 +124,6 @@ def get_storage() -> StorageBackend:
return _storage
def is_storage_initialized() -> bool:
"""Return True when the storage singleton has been initialized.
Lets callers on lifecycle / early-startup paths consult storage
without tripping :func:`get_storage`'s SQLite auto-init side effect
(which would create ``.turnstone.db`` in the CWD). Use this to guard
a best-effort read that should simply be skipped before the host has
called :func:`init_storage` never as a substitute for the explicit
init the app's startup performs.
"""
return _storage is not None
def reset_storage() -> None:
"""Close and clear the storage backend singleton (for tests)."""
global _storage
-6
View File
@@ -1209,12 +1209,6 @@ class SQLiteBackend:
workstreams.c.skill_id,
workstreams.c.skill_version,
workstreams.c.user_id,
# Appended after ``user_id`` so positional fallbacks in
# consumers (``_coord_children_row`` et al.) that index
# up to row[9] stay valid; ``_coordinator_rows`` reads
# these by name to surface the persisted display title.
workstreams.c.title,
workstreams.c.alias,
)
.order_by(workstreams.c.updated.desc())
.limit(limit)
+3 -12
View File
@@ -294,6 +294,8 @@ class TLSClient:
Discovery, CA fetch, and cert request are all idempotent, so the whole
sequence is retried as a unit.
"""
import asyncio
if attempts < 1:
# range(1, attempts + 1) would be empty: init() would return
# "successfully" with no CA and no cert.
@@ -319,18 +321,7 @@ class TLSClient:
delay_seconds=delay,
error=f"{type(exc).__name__}: {exc}",
)
await self._sleep(delay)
async def _sleep(self, delay: float) -> None:
"""Backoff sleep behind a seam so tests can stub it in isolation.
Patching the module-global ``asyncio.sleep`` would also intercept it
for every other task sharing the event loop; routing the retry backoff
through a method keeps test stubs from corrupting concurrent tasks.
"""
import asyncio
await asyncio.sleep(delay)
await asyncio.sleep(delay)
def _discover_console_url(self) -> str:
"""Look up the console URL from the services table."""
+71 -108
View File
@@ -39,7 +39,7 @@ from sse_starlette import EventSourceResponse
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
@@ -77,12 +77,10 @@ from turnstone.core.session_routes import (
make_history_handler,
make_list_handler,
make_open_handler,
make_refresh_title_handler,
make_retry_handler,
make_rewind_handler,
make_saved_handler,
make_send_handler,
make_set_title_handler,
register_session_routes,
)
from turnstone.core.session_ui_base import (
@@ -1292,100 +1290,6 @@ async def speech_to_text(request: Request) -> JSONResponse:
)
async def speech_to_text_stream(request: Request) -> Response:
"""POST /v1/api/workstreams/{ws_id}/speech-to-text/stream — stream the
transcript as plain-text deltas for lower perceived latency than the JSON
``speech-to-text`` endpoint. Resolve/transcode failures surface as
503 / 502 before any bytes are sent; once streaming begins the body is
best-effort (a mid-stream backend error just ends the partial stream)."""
from turnstone.core.audio import (
AudioBackendError,
AudioUnavailableError,
resolve_role_alias,
transcribe_stream,
)
from turnstone.core.web_helpers import read_multipart_file_or_400
ws_id = request.path_params.get("ws_id", "")
if not ws_id:
return JSONResponse({"error": "ws_id is required"}, status_code=400)
_user_id, err = _require_ws_access(request, ws_id)
if err:
return err
registry = getattr(request.app.state, "registry", None)
config_store = getattr(request.app.state, "config_store", None)
alias = resolve_role_alias(config_store=config_store, registry=registry, role="stt")
if not alias:
return JSONResponse(
{
"error": (
"Speech-to-text is not configured. Assign an STT model role in Models → Roles."
)
},
status_code=503,
)
got = await read_multipart_file_or_400(request, field="audio", max_bytes=_STT_UPLOAD_CAP)
if isinstance(got, JSONResponse):
return got
_filename, _claimed_mime, data = got
if not data:
return JSONResponse({"error": "Empty audio upload"}, status_code=400)
stt_prompt = ""
if config_store is not None:
stt_prompt = (config_store.get("audio.stt_prompt") or "").strip()
# Resolve + transcode + open the stream eagerly (off the event loop) so the
# common failures map to a clean status before any bytes are sent.
try:
deltas = await asyncio.to_thread(
transcribe_stream, registry=registry, alias=alias, data=data, prompt=stt_prompt
)
except AudioUnavailableError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except AudioBackendError:
log.warning("speech_to_text_stream.backend_failed", exc_info=True)
return JSONResponse({"error": "Speech transcription backend failed"}, status_code=502)
# Drive the blocking stream from one worker thread that owns (and closes)
# the upstream connection, handing deltas to the loop via a queue. A client
# disconnect sets ``stop`` so the thread releases the connection promptly
# instead of being pinned mid-``next()`` (which can't be cancelled).
async def _body() -> AsyncGenerator[bytes, None]:
loop = asyncio.get_running_loop()
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
stop = threading.Event()
def _pump() -> None:
try:
for delta in deltas:
if stop.is_set():
break
loop.call_soon_threadsafe(queue.put_nowait, delta.encode("utf-8"))
except Exception:
# Mid-stream backend failure: end the partial stream (logged).
log.warning("speech_to_text_stream.mid_stream_failed", exc_info=True)
finally:
close = getattr(deltas, "close", None)
if callable(close):
close()
loop.call_soon_threadsafe(queue.put_nowait, None)
loop.run_in_executor(None, _pump)
try:
while True:
chunk = await queue.get()
if chunk is None:
break
yield chunk
finally:
stop.set()
return StreamingResponse(_body(), media_type="text/plain; charset=utf-8")
async def text_to_speech(request: Request) -> Response:
"""POST /v1/api/tts — synthesize assistant text into playable audio."""
from turnstone.core.audio import (
@@ -2311,6 +2215,74 @@ async def delete_workstream_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"error": "Delete failed"}, status_code=500)
async def refresh_workstream_title(request: Request, ws_id: str = "") -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/refresh-title — regenerate workstream title via LLM."""
from turnstone.core.log import get_logger
from turnstone.core.memory import get_workstream_display_name
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
log.info("ws.title.refresh_requested", ws_id=ws_id[:8] if ws_id else "empty")
mgr = request.app.state.workstreams
_owner, err = _require_ws_access(request, ws_id, mgr=mgr)
if err:
return err
ws = mgr.get(ws_id)
if not ws or not ws.session:
log.warning(
"ws.title.refresh_failed",
ws_id=ws_id[:8] if ws_id else "empty",
reason="workstream_not_found",
)
return JSONResponse({"error": "Workstream not found or not active"}, status_code=404)
# Fetch current title so the LLM can generate something different
current_title = get_workstream_display_name(ws_id) or ""
log.info("ws.title.refresh_triggered", ws_id=ws_id[:8], current_title=current_title[:50])
ws.session.request_title_refresh(current_title)
return JSONResponse({"status": "ok"})
async def set_workstream_title(request: Request, ws_id: str = "") -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/title — set workstream title manually.
Stores the user-chosen title as the workstream *alias* so it takes
priority over the LLM auto-generated title in the display name
fallback chain (alias -> title -> name).
"""
from turnstone.core.log import get_logger
from turnstone.core.memory import set_workstream_alias
from turnstone.core.web_helpers import read_json_or_400
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
log.info("ws.title.set_requested", ws_id=ws_id[:8] if ws_id else "empty")
if not ws_id:
return JSONResponse({"error": "ws_id is required"}, status_code=400)
mgr = request.app.state.workstreams
_owner, err = _require_ws_access(request, ws_id, mgr=mgr)
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
title = str(body.get("title", "")).strip()
if not title:
return JSONResponse({"error": "title is required"}, status_code=400)
title = title[:80]
if not set_workstream_alias(ws_id, title):
log.warning("ws.title.set_alias_conflict", ws_id=ws_id[:8], title=title[:50])
return JSONResponse(
{"error": "That name is already used by another workstream"},
status_code=409,
)
log.info("ws.title.set_alias_updated", ws_id=ws_id[:8])
ws = mgr.get(ws_id)
if ws and ws.session and ws.session.ui:
ws.session.ui.on_rename(title)
log.info("ws.title.set_success", ws_id=ws_id[:8], title=title)
return JSONResponse({"status": "ok", "title": title})
def _auth_user_id(request: Request) -> str:
"""Return the authenticated user's id (empty string when absent).
@@ -3893,8 +3865,6 @@ def create_app(
history_handler = make_history_handler(interactive_endpoint_config)
export_handler = make_export_handler(interactive_endpoint_config)
detail_handler = make_detail_handler(interactive_endpoint_config)
refresh_title_handler = make_refresh_title_handler(interactive_endpoint_config)
set_title_handler = make_set_title_handler(interactive_endpoint_config)
v1_routes: list[Any] = [
Route("/api/events/global", global_events_sse),
]
@@ -3909,8 +3879,8 @@ def create_app(
detail=detail_handler, # lifted: shared body (interactive feature gain)
open=open_handler, # lifted: shared body
close=close_handler, # lifted: shared body
refresh_title=refresh_title_handler, # lifted: shared body
set_title=set_title_handler, # lifted: shared body
refresh_title=refresh_workstream_title,
set_title=set_workstream_title,
send=send_handler, # lifted: shared body (P1.5)
dequeue=dequeue_handler, # lifted (P1.5) — DELETE /send
approve=approve_handler, # lifted: shared body
@@ -3931,13 +3901,6 @@ def create_app(
methods=["POST"],
)
)
v1_routes.append(
Route(
"/api/workstreams/{ws_id}/speech-to-text/stream",
speech_to_text_stream,
methods=["POST"],
)
)
v1_routes.append(Route("/api/tts", text_to_speech, methods=["POST"]))
app = Starlette(
+21 -46
View File
@@ -1580,62 +1580,32 @@ class Pane {
this._micBtn.classList.add("is-busy");
}
voiceAnnounce("Transcribing…");
const resetMic = () => {
if (this._micBtn && !this._micDenied) {
this._micBtn.disabled = !!this.busy;
this._micBtn.classList.remove("is-busy");
}
};
authFetch(
this._base +
"/v1/api/workstreams/" +
encodeURIComponent(this.wsId) +
"/speech-to-text/stream",
"/speech-to-text",
{ method: "POST", body: fd },
)
.then(async (r) => {
if (!r.ok) {
let msg = "Transcription failed";
try {
const body = await r.json();
if (body && body.error) msg = body.error;
} catch (_e) {
/* non-JSON error body */
}
showToast(msg, "error");
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
.then((res) => {
if (!res.ok) {
showToast(
(res.body && res.body.error) || "Transcription failed",
"error",
);
return;
}
// Stream transcript deltas into the composer as they arrive (first word
// in ~0.3s) instead of waiting for the whole transcript.
const reader = r.body.getReader();
const decoder = new TextDecoder();
let started = false;
let got = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
if (!chunk || !this.inputEl) continue;
got = true;
if (!started) {
// Read the composer's value now (not before the await) so text the
// user typed while transcribing isn't clobbered.
const cur = this.inputEl.value || "";
this.inputEl.value = cur
? cur.replace(/\s*$/, "") + " " + chunk
: chunk;
started = true;
} else {
this.inputEl.value += chunk;
}
const text = (res.body && res.body.transcript) || "";
if (text && this.inputEl) {
const cur = this.inputEl.value || "";
this.inputEl.value = cur
? cur.replace(/\s*$/, "") + " " + text
: text;
// Drive the composer's auto-resize + send-enable listeners.
this.inputEl.dispatchEvent(new Event("input", { bubbles: true }));
}
if (got) {
if (this.inputEl) this.inputEl.focus();
this.inputEl.focus();
voiceAnnounce("Transcript added to message.");
} else {
showToast("No speech detected", "error");
}
})
.catch((err) => {
@@ -1644,7 +1614,12 @@ class Pane {
"error",
);
})
.finally(resetMic);
.finally(() => {
if (this._micBtn && !this._micDenied) {
this._micBtn.disabled = !!this.busy;
this._micBtn.classList.remove("is-busy");
}
});
}
_addTtsAction(el) {
-5
View File
@@ -837,11 +837,6 @@ async function mountShell() {
});
pane.tabMenu = () =>
convTabMenu(pane, pm, id, {
// Coordinators carry titles like interactive workstreams now:
// surface Refresh/Edit title. The default base ("") targets the
// console origin, where the coord refresh-title / title routes
// are mounted (same base coordinator.js posts every verb to).
titleVerbs: true,
closeSession: () => {
if (pane._ctl && pane._ctl.closeSession) pane._ctl.closeSession();
},
Generated
+32 -32
View File
@@ -172,7 +172,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.108.0"
version = "0.109.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -184,9 +184,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/c7/d7f6d2e3975893958081f0282751217757333a3830d0d95859023d7006d0/anthropic-0.108.0.tar.gz", hash = "sha256:91b70253debb477a99f7ca43dac3f71e52207db79d4b06f104080b8dd1693e3b", size = 909409, upload-time = "2026-06-09T16:37:43.584Z" }
sdist = { url = "https://files.pythonhosted.org/packages/54/0b/ce24a4f275573f5e436ca954faca60c759d58ed152b8fa36a1e3b888e261/anthropic-0.109.1.tar.gz", hash = "sha256:83e06b3d9d40ff5898f588020e0cc4e42187de954549a3b5fbe6e2685a09c785", size = 927569, upload-time = "2026-06-09T23:55:24.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/40/75a937ddd8f230ec129d27de60df69ce8afcab1d0b15f7d651a5a95fac8a/anthropic-0.108.0-py3-none-any.whl", hash = "sha256:bdee7b14c13cf5a60b2c8ae0cf195720e0ea7fd8ab90df5a3899c50f1c91c4be", size = 870079, upload-time = "2026-06-09T16:37:44.895Z" },
{ url = "https://files.pythonhosted.org/packages/91/0f/a6110d713370bc92f074a622f8a5ebdec7e92360149b1048dca258a07b2f/anthropic-0.109.1-py3-none-any.whl", hash = "sha256:ce7d94a7657f2aa29338cca448945eac621b4f62c1794cf461cb32847223e9b8", size = 923851, upload-time = "2026-06-09T23:55:23.348Z" },
]
[[package]]
@@ -1419,7 +1419,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.41.0"
version = "2.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1431,9 +1431,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/36/4c926a91554483977608951360c18c2e911592785eb87a6437813f6123f7/openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57", size = 783584, upload-time = "2026-06-10T16:10:37.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" },
{ url = "https://files.pythonhosted.org/packages/20/74/925d7b3892927e9804aaf58d374a45dc28e4420ff90e992272b77286343e/openai-2.41.1-py3-none-any.whl", hash = "sha256:a939565f350cb7443cb843b801b88c716ac8024b492fb94ca269d5f6b1bbefd6", size = 1353380, upload-time = "2026-06-10T16:10:35.756Z" },
]
[[package]]
@@ -1924,7 +1924,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1933,9 +1933,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
]
[[package]]
@@ -2224,27 +2224,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.16"
version = "0.15.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
{ url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
{ url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
{ url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
{ url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
{ url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
{ url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
{ url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
{ url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
{ url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
{ url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
{ url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
{ url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
{ url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
{ url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
{ url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
{ url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
{ url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
{ url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
{ url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
{ url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
{ url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
]
[[package]]
@@ -2425,19 +2425,19 @@ wheels = [
[[package]]
name = "tqdm"
version = "4.68.1"
version = "4.68.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/b3/36c8ecf72e8925200671613332db156d84b99b3aee742a41c1938ebb0808/tqdm-4.68.1.tar.gz", hash = "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", size = 171236, upload-time = "2026-06-05T17:23:15.267Z" }
sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/aa/218a0eb34de1f753c83e4d0d1c8e7c4cef27f20dcb8342e024f63a80dc86/tqdm-4.68.1-py3-none-any.whl", hash = "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8", size = 78354, upload-time = "2026-06-05T17:23:13.654Z" },
{ url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578, upload-time = "2026-06-09T13:26:40.731Z" },
]
[[package]]
name = "turnstone"
version = "1.6.9"
version = "1.7.0a2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },