mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7000ef03a8 | |||
| 0ae0db55f3 | |||
| 8256e7441a | |||
| 1f121c739c | |||
| 026acbf907 | |||
| 49013a5593 | |||
| ac68efba45 | |||
| ea5b727ae9 | |||
| 87e189ae7d | |||
| 795193fa00 | |||
| 155fbb1427 | |||
| 7e3ec8dea5 | |||
| 6560f1ec4f | |||
| 093239d614 | |||
| f5ab26b4dc | |||
| 1da001deed | |||
| 863f0fd6e2 | |||
| fe2403f810 | |||
| 8292ba177c | |||
| 6de1f166b8 | |||
| c955001372 | |||
| 4556a04b5e | |||
| 4a8c908ce9 | |||
| bd86a0fb09 | |||
| b900b20c40 | |||
| 0b02838a42 | |||
| c099ed030e | |||
| d81b312b41 | |||
| 1251ecaa11 | |||
| 7939d70b36 | |||
| f01e02d443 | |||
| d55c0dc27f | |||
| e39f957097 | |||
| 80b5807597 | |||
| 04e01e101c | |||
| 96b52351dd | |||
| f8b7cc23cb | |||
| 218f1067ff | |||
| 7c70bb2b89 | |||
| cd7e3ab787 | |||
| f84b9a4219 | |||
| 315df67877 | |||
| 4332997d59 | |||
| efae51dc50 | |||
| 278aec0ce4 | |||
| 5fed6d7b08 | |||
| de5462beb5 | |||
| bfb8a970dd | |||
| b5c1baf29d | |||
| fd8ec8ad18 | |||
| 04b3e8e36f | |||
| 80530aba94 | |||
| fb77fcd805 | |||
| 0cdf5e9af5 | |||
| ab61799f66 | |||
| 63df3e1750 | |||
| 305a0a3af4 | |||
| af055b342c | |||
| 34948bf09b | |||
| 0cc59d7e0f | |||
| ed243cca73 | |||
| 1b5b466d47 | |||
| 6ea7756752 | |||
| 9419ad6735 | |||
| 47d88b4196 | |||
| 95f5c5e50e | |||
| eeeb8f1b4d | |||
| 6dff81bece | |||
| 549e15f2f6 | |||
| 52bea510e2 | |||
| 1f700bf72a | |||
| 6b353ea225 | |||
| caafac901e | |||
| 745d6ece59 | |||
| 75b9222a7f | |||
| 70cc8dd97d | |||
| 019d13d930 | |||
| 8fbcbff566 | |||
| a27738867f | |||
| 1d0be9773f | |||
| ad56e1ec96 | |||
| 8f0115ee2e | |||
| 7aba631201 | |||
| f3f5e84f2d | |||
| ff1e3e5c1c | |||
| f0d7305b28 | |||
| 84a545cb21 | |||
| 8e11929ba0 | |||
| d9e9a41b17 | |||
| 848b2cc1fb | |||
| 1946002618 | |||
| 4bce6abc7c | |||
| c19432f12a |
@@ -35,6 +35,9 @@ 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"]
|
||||
@@ -51,7 +54,10 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
|
||||
# -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
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
@@ -60,6 +66,7 @@ jobs:
|
||||
|
||||
test-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
@@ -83,7 +90,7 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -v
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
|
||||
|
||||
+3
-1
@@ -17,8 +17,10 @@ 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 \
|
||||
libpq5 git curl jq man-db manpages procps file ripgrep ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
|
||||
|
||||
+6
-5
@@ -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 = 60.0 # seconds (generous for local models)
|
||||
timeout = 120.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: 60)
|
||||
--judge-timeout SECONDS LLM judge timeout (default: 120)
|
||||
--judge-confidence FLOAT Confidence threshold, 0-1 (default: 0.95)
|
||||
```
|
||||
|
||||
@@ -193,9 +193,10 @@ Security hardening blocks access to sensitive paths:
|
||||
|
||||
### Timeout
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.7.0a2"
|
||||
version = "1.6.9"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -95,7 +95,10 @@ include = [
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = ["live: requires a running LLM backend"]
|
||||
markers = [
|
||||
"live: requires a running LLM backend",
|
||||
"allow_thread_leak: test intentionally leaves a background thread running (opts out of the leaked-thread guard)",
|
||||
]
|
||||
filterwarnings = [
|
||||
# mcp v1 deprecates streamablehttp_client for an entry point whose call
|
||||
# shape only settles in v2 — adoption rides the deliberate v2 migration
|
||||
|
||||
Generated
+51
-51
@@ -55,14 +55,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -409,16 +409,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -427,13 +427,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -454,9 +454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -467,13 +467,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -481,14 +481,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -497,9 +497,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -507,13 +507,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -921,9 +921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
|
||||
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
@@ -1200,19 +1200,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"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.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",
|
||||
"@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",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -1,17 +1,118 @@
|
||||
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.
|
||||
|
||||
|
||||
+202
-5
@@ -7,6 +7,7 @@ helper code runs end-to-end without a network call.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -18,11 +19,16 @@ class _Cfg:
|
||||
"""Stand-in for ModelConfig — only the fields audio.py reads."""
|
||||
|
||||
def __init__(
|
||||
self, model: str, capabilities: dict | None = None, provider: str = "openai"
|
||||
self,
|
||||
model: str,
|
||||
capabilities: dict | None = None,
|
||||
provider: str = "openai",
|
||||
server_compat: dict | None = None,
|
||||
) -> None:
|
||||
self.model = model
|
||||
self.capabilities = capabilities or {}
|
||||
self.provider = provider
|
||||
self.server_compat = server_compat or {}
|
||||
|
||||
|
||||
class _FakeConfigStore:
|
||||
@@ -191,7 +197,8 @@ 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):
|
||||
def test_omni_model_transcribes_via_chat(self, monkeypatch):
|
||||
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
||||
client = MagicMock()
|
||||
msg = MagicMock(content=" the transcript ")
|
||||
client.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=msg)])
|
||||
@@ -202,15 +209,18 @@ 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"] == "webm"
|
||||
assert audio_part["input_audio"]["format"] == "wav"
|
||||
# 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):
|
||||
def test_omni_prompt_override_used(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="x"))]
|
||||
@@ -338,3 +348,190 @@ 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()
|
||||
|
||||
@@ -85,6 +85,59 @@ 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
|
||||
|
||||
@@ -65,8 +65,10 @@ 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
|
||||
|
||||
@@ -204,6 +206,16 @@ 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),
|
||||
@@ -370,6 +382,114 @@ 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,
|
||||
@@ -2433,6 +2553,54 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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}"
|
||||
@@ -52,13 +52,32 @@ class _Handler(http.server.BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -90,29 +109,29 @@ def mtls_setup(tmp_path):
|
||||
# ── Plain HTTP (mTLS disabled — the default deployment) ─────────────────────
|
||||
|
||||
|
||||
def test_plain_http_ok():
|
||||
def test_plain_http_ok(serve):
|
||||
"""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():
|
||||
def test_plain_http_degraded_is_healthy(serve):
|
||||
"""'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():
|
||||
def test_plain_http_bad_status_fails(serve):
|
||||
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
|
||||
@@ -128,40 +147,40 @@ def test_server_down_fails():
|
||||
# ── mTLS (tls.enabled) ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mtls_probe_with_pem_dir(mtls_setup):
|
||||
def test_mtls_probe_with_pem_dir(mtls_setup, serve):
|
||||
"""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):
|
||||
def test_mtls_probe_without_pems_fails(mtls_setup, serve):
|
||||
"""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):
|
||||
def test_mtls_unhealthy_payload_fails(mtls_setup, serve):
|
||||
"""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):
|
||||
def test_mtls_incomplete_pem_dir_fails(mtls_setup, tmp_path, serve):
|
||||
"""A PEM dir missing the key is skipped, not half-used."""
|
||||
_, server_ctx = mtls_setup
|
||||
incomplete = tmp_path / "incomplete-root"
|
||||
@@ -170,7 +189,7 @@ def test_mtls_incomplete_pem_dir_fails(mtls_setup, tmp_path):
|
||||
(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
|
||||
|
||||
|
||||
+44
-61
@@ -5,7 +5,6 @@ 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
|
||||
@@ -120,8 +119,7 @@ class TestVerdictParsing:
|
||||
[{"role": "user", "content": "Run echo hello"}],
|
||||
callback_results.append,
|
||||
)
|
||||
# Wait for daemon thread
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert len(heuristics) == 1
|
||||
assert heuristics[0].tier == "heuristic"
|
||||
@@ -184,14 +182,12 @@ class TestErrorHandling:
|
||||
provider = _make_mock_provider(side_effect=RuntimeError("API error"))
|
||||
judge = _make_judge(provider)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_provider_error_heuristic_still_returned(self):
|
||||
@@ -209,7 +205,7 @@ class TestErrorHandling:
|
||||
[{"role": "user", "content": "test"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert len(heuristics) == 1
|
||||
assert heuristics[0].tier == "heuristic"
|
||||
@@ -233,20 +229,19 @@ class TestErrorHandling:
|
||||
[{"role": "user", "content": "test"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
assert len(callback_results) == 1
|
||||
assert callback_results[0].tier == "llm_fallback"
|
||||
|
||||
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
|
||||
|
||||
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."""
|
||||
judge = _make_judge()
|
||||
judge._evaluate_single = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=_ExecutorPoisonedError()
|
||||
return_value=None
|
||||
)
|
||||
callback_results: list[IntentVerdict] = []
|
||||
judge.evaluate(
|
||||
@@ -254,7 +249,7 @@ class TestErrorHandling:
|
||||
[{"role": "user", "content": "test"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
assert len(callback_results) == 1
|
||||
assert callback_results[0].tier == "llm_fallback"
|
||||
|
||||
@@ -266,14 +261,12 @@ class TestErrorHandling:
|
||||
result_mock.content = ""
|
||||
|
||||
judge = _make_judge(provider)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_content_length_stop_no_retry(self):
|
||||
@@ -285,14 +278,12 @@ class TestErrorHandling:
|
||||
result_mock.finish_reason = "length"
|
||||
|
||||
judge = _make_judge(provider)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
# Should have been called exactly once — no retries
|
||||
assert provider.create_completion.call_count == 1
|
||||
@@ -404,14 +395,12 @@ class TestMultiTurnToolUse:
|
||||
provider.create_completion.side_effect = [turn1, turn2]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert verdict is not None
|
||||
assert verdict.tier == "llm"
|
||||
assert provider.create_completion.call_count == 2
|
||||
@@ -454,14 +443,12 @@ class TestMultiTurnToolUse:
|
||||
]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
|
||||
assert provider.create_completion.call_count == 5
|
||||
|
||||
@@ -507,7 +494,7 @@ class TestConfidenceArbitration:
|
||||
[{"role": "user", "content": "Run echo hello"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert len(heuristics) == 1
|
||||
assert heuristics[0].confidence == 0.85
|
||||
@@ -527,7 +514,7 @@ class TestConfidenceArbitration:
|
||||
[{"role": "user", "content": "Run echo hello"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert len(heuristics) == 1
|
||||
# LLM verdict is always delivered regardless of confidence comparison
|
||||
@@ -966,11 +953,7 @@ class TestModelAliasResolution:
|
||||
[{"role": "user", "content": "delegate the audit"}],
|
||||
callback_results.append,
|
||||
)
|
||||
# Wait for daemon thread.
|
||||
for _ in range(20):
|
||||
if callback_results:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert callback_results, "judge never delivered a verdict"
|
||||
assert callback_results[0].tier == "llm"
|
||||
|
||||
@@ -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
|
||||
from tests.conftest import make_mcp_token_cipher, serve_until_exit, stop_loop_thread
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
from turnstone.core.mcp_crypto import MCPTokenStore
|
||||
from turnstone.core.mcp_oauth import TokenLookupResult
|
||||
@@ -187,7 +187,14 @@ 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)
|
||||
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
|
||||
)
|
||||
return uvicorn.Server(config)
|
||||
|
||||
|
||||
@@ -215,9 +222,7 @@ def upstream():
|
||||
server = _build_server(port, behaviour)
|
||||
|
||||
def _run() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(server.serve())
|
||||
serve_until_exit(server)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True, name="phase6-upstream")
|
||||
t.start()
|
||||
@@ -225,7 +230,11 @@ 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)
|
||||
|
||||
|
||||
@@ -311,8 +320,7 @@ def running_loop_mgr():
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=2)
|
||||
stop_loop_thread(loop, thread)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,7 +34,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.conftest import make_mcp_token_cipher
|
||||
from tests.conftest import make_mcp_token_cipher, stop_loop_thread
|
||||
from turnstone.core.mcp_client import (
|
||||
MCPClientManager,
|
||||
_AuthCapture,
|
||||
@@ -131,8 +131,7 @@ def running_loop_mgr():
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=2)
|
||||
stop_loop_thread(loop, thread)
|
||||
|
||||
|
||||
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any) -> Any:
|
||||
|
||||
@@ -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
|
||||
from tests.conftest import make_mcp_token_cipher, serve_until_exit, stop_loop_thread
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
from turnstone.core.mcp_crypto import MCPTokenStore
|
||||
from turnstone.core.mcp_oauth import TokenLookupResult
|
||||
@@ -130,7 +130,14 @@ 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)
|
||||
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
|
||||
)
|
||||
return uvicorn.Server(config)
|
||||
|
||||
|
||||
@@ -152,9 +159,7 @@ def upstream():
|
||||
server = _build_server(port, behaviour)
|
||||
|
||||
def _run() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(server.serve())
|
||||
serve_until_exit(server)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True, name="phase7b-prompt-upstream")
|
||||
t.start()
|
||||
@@ -162,7 +167,11 @@ 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)
|
||||
|
||||
|
||||
@@ -248,8 +257,7 @@ def running_loop_mgr():
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=2)
|
||||
stop_loop_thread(loop, thread)
|
||||
|
||||
|
||||
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
|
||||
from tests.conftest import make_mcp_token_cipher, serve_until_exit, stop_loop_thread
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
from turnstone.core.mcp_crypto import MCPTokenStore
|
||||
from turnstone.core.mcp_oauth import TokenLookupResult
|
||||
@@ -139,7 +139,14 @@ 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)
|
||||
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
|
||||
)
|
||||
return uvicorn.Server(config)
|
||||
|
||||
|
||||
@@ -161,9 +168,7 @@ def upstream():
|
||||
server = _build_server(port, behaviour)
|
||||
|
||||
def _run() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(server.serve())
|
||||
serve_until_exit(server)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True, name="phase7b-resource-upstream")
|
||||
t.start()
|
||||
@@ -171,7 +176,11 @@ 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,8 +266,7 @@ def running_loop_mgr():
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=2)
|
||||
stop_loop_thread(loop, thread)
|
||||
|
||||
|
||||
def _seed_pool_resource_map(
|
||||
|
||||
@@ -25,7 +25,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import make_mcp_token_cipher
|
||||
from tests.conftest import make_mcp_token_cipher, stop_loop_thread
|
||||
from turnstone.core.mcp_client import MCPClientManager, PoolEntryState
|
||||
from turnstone.core.mcp_crypto import MCPTokenStore
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
@@ -123,8 +123,7 @@ def running_loop_mgr():
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=2)
|
||||
stop_loop_thread(loop, thread)
|
||||
|
||||
|
||||
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any) -> Any:
|
||||
|
||||
@@ -220,6 +220,26 @@ 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:
|
||||
|
||||
+20
-13
@@ -19,7 +19,8 @@ class TestSuggestProfile:
|
||||
p = suggest_profile("vllm", "google/gemma-4-31B-it")
|
||||
assert p["capabilities"]["thinking_mode"] == "manual"
|
||||
assert p["capabilities"]["thinking_param"] == "enable_thinking"
|
||||
assert p["server_compat"]["extra_body"]["skip_special_tokens"] is False
|
||||
# No bug-workaround extra_body — gemma-4 needs only the thinking param.
|
||||
assert "extra_body" not in p["server_compat"]
|
||||
|
||||
def test_vllm_gemma3(self) -> None:
|
||||
p = suggest_profile("vllm", "google/gemma-3-27b-it")
|
||||
@@ -147,14 +148,14 @@ class TestMergeServerCompat:
|
||||
result = merge_server_compat(None, {"extra_body": {"skip_special_tokens": False}})
|
||||
assert result == {"skip_special_tokens": False}
|
||||
|
||||
def test_full_vllm_gemma_compat_no_base(self) -> None:
|
||||
"""vLLM workaround forwards on its own."""
|
||||
def test_full_server_compat_extra_body_no_base(self) -> None:
|
||||
"""A server workaround (e.g. llama.cpp reasoning_format) forwards on its own."""
|
||||
compat = {
|
||||
"server_type": "vllm",
|
||||
"extra_body": {"skip_special_tokens": False},
|
||||
"server_type": "llama.cpp",
|
||||
"extra_body": {"reasoning_format": "auto"},
|
||||
}
|
||||
result = merge_server_compat(None, compat)
|
||||
assert result == {"skip_special_tokens": False}
|
||||
assert result == {"reasoning_format": "auto"}
|
||||
|
||||
def test_operator_chat_template_kwargs_only(self) -> None:
|
||||
"""Operator can set chat_template_kwargs explicitly without seeding the base."""
|
||||
@@ -210,21 +211,27 @@ class TestEndToEndRequestShaping:
|
||||
"""Compose both layers — session builds extra_params, provider applies thinking."""
|
||||
|
||||
def test_vllm_gemma_full_flow(self) -> None:
|
||||
"""Session forwards server workarounds, provider adds thinking param."""
|
||||
"""Gemma now needs only the thinking param — no server workaround."""
|
||||
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
|
||||
server_compat = {
|
||||
"server_type": "vllm",
|
||||
"extra_body": {"skip_special_tokens": False},
|
||||
}
|
||||
server_compat = {"server_type": "vllm"}
|
||||
# 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},
|
||||
"skip_special_tokens": False,
|
||||
"reasoning_format": "auto",
|
||||
}
|
||||
|
||||
def test_granite_thinking_key(self) -> None:
|
||||
@@ -292,7 +299,7 @@ class TestProbeIntegration:
|
||||
assert result["server_type"] == "vllm"
|
||||
assert result["suggested_capabilities"]["thinking_mode"] == "manual"
|
||||
assert result["suggested_capabilities"]["thinking_param"] == "enable_thinking"
|
||||
assert result["suggested_server_compat"]["extra_body"]["skip_special_tokens"] is False
|
||||
assert "extra_body" not in result["suggested_server_compat"]
|
||||
|
||||
def test_detect_non_thinking_no_suggested_capabilities(self) -> None:
|
||||
"""Non-thinking vLLM model gets server_compat but no capabilities suggestion."""
|
||||
|
||||
@@ -150,6 +150,27 @@ 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.
|
||||
|
||||
@@ -1010,6 +1031,66 @@ 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."""
|
||||
|
||||
@@ -602,6 +602,27 @@ 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
|
||||
|
||||
@@ -96,10 +96,8 @@ 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 (asyncio.sleep is stubbed out).
|
||||
attempt and each backoff delay (the client's backoff sleep is stubbed).
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(
|
||||
@@ -119,11 +117,14 @@ 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(asyncio, "sleep", fake_sleep)
|
||||
monkeypatch.setattr(client, "_sleep", fake_sleep)
|
||||
return client, calls, sleeps
|
||||
|
||||
|
||||
@@ -175,8 +176,6 @@ 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"])
|
||||
@@ -191,10 +190,13 @@ 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(asyncio, "sleep", lambda _: ok())
|
||||
monkeypatch.setattr(client, "_sleep", fake_sleep)
|
||||
|
||||
await client.init(attempts=2)
|
||||
assert attempts == [1, 2]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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"
|
||||
@@ -31,16 +31,17 @@ 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,
|
||||
)
|
||||
|
||||
@@ -113,6 +114,18 @@ 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(
|
||||
@@ -120,12 +133,12 @@ def title_client(_inject_storage):
|
||||
routes=[
|
||||
Route(
|
||||
"/api/workstreams/{ws_id}/title",
|
||||
set_workstream_title,
|
||||
make_set_title_handler(cfg),
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/workstreams/{ws_id}/refresh-title",
|
||||
refresh_workstream_title,
|
||||
make_refresh_title_handler(cfg),
|
||||
methods=["POST"],
|
||||
),
|
||||
],
|
||||
@@ -133,7 +146,6 @@ def title_client(_inject_storage):
|
||||
],
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
mock_mgr = MagicMock()
|
||||
app.state.workstreams = mock_mgr
|
||||
return TestClient(app), mock_mgr
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.7.0a2"
|
||||
__version__ = "1.6.9"
|
||||
|
||||
+2
-2
@@ -1022,8 +1022,8 @@ def main() -> None:
|
||||
"--judge-timeout",
|
||||
dest="judge_timeout",
|
||||
type=float,
|
||||
default=60.0,
|
||||
help="LLM judge timeout in seconds (default: 60)",
|
||||
default=120.0,
|
||||
help="LLM judge timeout in seconds (default: 120)",
|
||||
)
|
||||
judge_group.add_argument(
|
||||
"--judge-confidence",
|
||||
|
||||
@@ -94,6 +94,11 @@ 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]]] = []
|
||||
@@ -135,6 +140,9 @@ 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
|
||||
@@ -161,6 +169,7 @@ 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()
|
||||
@@ -417,7 +426,9 @@ class ClusterCollector:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("Node discovery error")
|
||||
time.sleep(self._discovery_interval)
|
||||
# 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)
|
||||
|
||||
def _discover_nodes(self) -> None:
|
||||
"""Query the service registry and update the node map."""
|
||||
|
||||
@@ -23,6 +23,8 @@ 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:
|
||||
@@ -37,6 +39,28 @@ 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."""
|
||||
|
||||
@@ -132,7 +156,7 @@ class CoordinatorAdapter:
|
||||
try:
|
||||
self._collector.emit_console_ws_created(
|
||||
ws.id,
|
||||
name=ws.name,
|
||||
name=_coord_display_name(ws),
|
||||
user_id=ws.user_id,
|
||||
kind=ws.kind.value,
|
||||
state=ws.state.value,
|
||||
@@ -466,11 +490,16 @@ 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.)
|
||||
for ws in mgr.list_all():
|
||||
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:
|
||||
try:
|
||||
collector.emit_console_ws_created(
|
||||
ws.id,
|
||||
name=ws.name,
|
||||
name=seed_names.get(ws.id) or ws.name,
|
||||
user_id=ws.user_id or "",
|
||||
kind=WorkstreamKind.COORDINATOR.value,
|
||||
state=ws.state.value,
|
||||
|
||||
+82
-37
@@ -55,6 +55,8 @@ 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 (
|
||||
@@ -74,9 +76,11 @@ 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,
|
||||
@@ -852,6 +856,14 @@ 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.
|
||||
@@ -869,6 +881,61 @@ 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:
|
||||
@@ -876,9 +943,9 @@ def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
|
||||
rows.append(
|
||||
{
|
||||
"id": ws.id,
|
||||
"name": ws.name,
|
||||
"name": live_display.get(ws.id) or ws.name,
|
||||
"state": ws.state.value,
|
||||
"title": "",
|
||||
"title": _title(ws.id),
|
||||
"node": "console",
|
||||
"server_url": "",
|
||||
"model": _str_sess_attr(sess, "model"),
|
||||
@@ -895,30 +962,7 @@ 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:
|
||||
@@ -927,9 +971,9 @@ def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
|
||||
rows.append(
|
||||
{
|
||||
"id": row_id,
|
||||
"name": m.get("name") or f"coord-{row_id[:4]}",
|
||||
"name": _display_name(row_id, f"coord-{row_id[:4]}"),
|
||||
"state": str(m.get("state") or "idle"),
|
||||
"title": "",
|
||||
"title": _title(row_id),
|
||||
"node": "console",
|
||||
"server_url": "",
|
||||
"model": "",
|
||||
@@ -11530,16 +11574,15 @@ def _validate_regex_pattern(pattern: str, flags: int = 0) -> str | None:
|
||||
compiled.search(s)
|
||||
|
||||
try:
|
||||
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)
|
||||
# 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"
|
||||
except Exception:
|
||||
return "Regex caused an error during test"
|
||||
return None
|
||||
@@ -12979,6 +13022,8 @@ 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
|
||||
|
||||
+197
-18
@@ -15,11 +15,16 @@ 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 Any
|
||||
from typing import TYPE_CHECKING, 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__)
|
||||
|
||||
@@ -83,6 +88,22 @@ _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:
|
||||
@@ -185,30 +206,115 @@ def _serves_transcription_endpoint(cfg: Any, model: str) -> bool:
|
||||
return _infer_audio_capability(model, "stt")
|
||||
|
||||
|
||||
def _transcribe_via_chat(client: Any, model: str, data: bytes, filename: str, prompt: str) -> str:
|
||||
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:
|
||||
"""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 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).
|
||||
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.
|
||||
"""
|
||||
import base64
|
||||
|
||||
name = filename or "speech.webm"
|
||||
fmt = name.rsplit(".", 1)[-1].lower() if "." in name else "wav"
|
||||
audio_b64 = base64.b64encode(data).decode("ascii")
|
||||
wav = _to_wav_16k_mono(data)
|
||||
audio_b64 = base64.b64encode(wav).decode("ascii")
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": fmt}},
|
||||
],
|
||||
}
|
||||
],
|
||||
messages=_omni_chat_messages(prompt, audio_b64),
|
||||
max_tokens=max_tokens,
|
||||
extra_body=extra_body or None,
|
||||
)
|
||||
choices = getattr(resp, "choices", None) or []
|
||||
if not choices:
|
||||
@@ -261,13 +367,86 @@ def transcribe(
|
||||
transcript = (getattr(resp, "text", "") or "").strip()
|
||||
else:
|
||||
transcript = _transcribe_via_chat(
|
||||
client, model, data, filename, prompt or _OMNI_STT_PROMPT
|
||||
client,
|
||||
model,
|
||||
data,
|
||||
prompt or _OMNI_STT_PROMPT,
|
||||
extra_body=_omni_chat_extra_body(cfg),
|
||||
)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""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]
|
||||
+33
-50
@@ -15,11 +15,16 @@ 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:
|
||||
@@ -76,8 +81,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 60 s and a maximum of 5 turns, a
|
||||
single tool-call evaluation can take up to 300 s in the worst case
|
||||
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
|
||||
(e.g. a multi-turn tool-use exchange with a slow local model).
|
||||
"""
|
||||
|
||||
@@ -86,13 +91,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 = 60.0 # per-turn timeout in seconds (see class docstring)
|
||||
timeout: float = 120.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 = 30.0 # wall-clock budget for the LLM stage
|
||||
output_guard_llm_timeout: float = 60.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
|
||||
@@ -881,10 +886,6 @@ 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.
|
||||
|
||||
@@ -1054,7 +1055,6 @@ 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,7 +1071,6 @@ class IntentJudge:
|
||||
item,
|
||||
messages,
|
||||
cancel_event,
|
||||
executor,
|
||||
client,
|
||||
)
|
||||
if llm_verdict:
|
||||
@@ -1116,17 +1115,6 @@ 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",
|
||||
@@ -1134,7 +1122,6 @@ 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()
|
||||
@@ -1172,7 +1159,6 @@ 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."""
|
||||
@@ -1237,32 +1223,29 @@ class IntentJudge:
|
||||
# models aren't penalised for slow earlier turns.
|
||||
per_call_timeout = max(self._config.timeout, 5.0) # at least 5s
|
||||
try:
|
||||
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",
|
||||
# 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",
|
||||
)
|
||||
# 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:
|
||||
except DeadlineCancelledError:
|
||||
return None
|
||||
except DeadlineExceededError:
|
||||
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.
|
||||
@@ -1277,7 +1260,7 @@ class IntentJudge:
|
||||
if verdict:
|
||||
log.info("judge.verdict.from_partial", turn=turn + 1)
|
||||
return verdict
|
||||
raise _ExecutorPoisonedError from None
|
||||
return None
|
||||
except Exception as e:
|
||||
log.info("judge.turn.failed", turn=turn + 1, error=str(e))
|
||||
return None
|
||||
|
||||
@@ -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
|
||||
: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``.
|
||||
: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)``.
|
||||
- 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,11 +39,15 @@ 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:
|
||||
@@ -370,9 +374,10 @@ 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 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.
|
||||
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.
|
||||
"""
|
||||
if not output:
|
||||
return OutputJudgeVerdict(
|
||||
@@ -407,15 +412,16 @@ class OutputGuardJudge:
|
||||
verdict_id, call_id, start, f"client_create_failed: {type(e).__name__}"
|
||||
)
|
||||
|
||||
# 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")
|
||||
# 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.)
|
||||
try:
|
||||
try:
|
||||
future = ex.submit(
|
||||
self._provider.create_completion,
|
||||
result = run_with_deadline(
|
||||
lambda: self._provider.create_completion(
|
||||
client=client,
|
||||
model=self._model,
|
||||
messages=judge_messages,
|
||||
@@ -423,27 +429,19 @@ class OutputGuardJudge:
|
||||
max_tokens=512,
|
||||
temperature=0.0,
|
||||
reasoning_effort="low",
|
||||
)
|
||||
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)
|
||||
),
|
||||
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__}"
|
||||
)
|
||||
|
||||
content = (getattr(result, "content", "") or "").strip()
|
||||
if not content:
|
||||
|
||||
@@ -14,10 +14,12 @@ 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
|
||||
``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.
|
||||
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.
|
||||
|
||||
Profiles are *suggestions* only. The admin UI auto-fills them on
|
||||
Detect; the operator has final say, and the stored DB config is what
|
||||
@@ -45,11 +47,6 @@ _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": {
|
||||
|
||||
@@ -2425,10 +2425,15 @@ 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
|
||||
# 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".
|
||||
user_msg = ""
|
||||
asst_msg = ""
|
||||
for m in self.messages:
|
||||
for m in list(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]
|
||||
@@ -4150,6 +4155,25 @@ 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
|
||||
@@ -4292,10 +4316,6 @@ 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
|
||||
|
||||
@@ -493,9 +493,8 @@ 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`` / ``refresh_title``
|
||||
/ ``set_title`` / attachments; interactive populates every
|
||||
interaction verb post-Stage-2.
|
||||
describes either kind — coord omits ``delete``; interactive
|
||||
populates every interaction verb post-Stage-2.
|
||||
"""
|
||||
|
||||
# Listing
|
||||
@@ -972,6 +971,122 @@ 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,
|
||||
|
||||
@@ -201,8 +201,18 @@ 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`` or
|
||||
``threading.Event`` primitives.
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
|
||||
|
||||
@@ -577,7 +577,7 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
SettingDef(
|
||||
"judge.timeout",
|
||||
"float",
|
||||
60.0,
|
||||
120.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",
|
||||
30.0,
|
||||
60.0,
|
||||
"Wall-clock budget for the output-guard LLM judge call",
|
||||
"judge",
|
||||
min_value=1.0,
|
||||
|
||||
@@ -8,6 +8,7 @@ from turnstone.core.storage._registry import (
|
||||
StorageUnavailableError,
|
||||
get_storage,
|
||||
init_storage,
|
||||
is_storage_initialized,
|
||||
reset_storage,
|
||||
)
|
||||
|
||||
@@ -17,5 +18,6 @@ __all__ = [
|
||||
"StorageUnavailableError",
|
||||
"get_storage",
|
||||
"init_storage",
|
||||
"is_storage_initialized",
|
||||
"reset_storage",
|
||||
]
|
||||
|
||||
@@ -1052,6 +1052,12 @@ 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)
|
||||
|
||||
@@ -679,7 +679,9 @@ 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``).
|
||||
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).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -124,6 +124,19 @@ 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
|
||||
|
||||
@@ -1209,6 +1209,12 @@ 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)
|
||||
|
||||
+12
-3
@@ -294,8 +294,6 @@ 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.
|
||||
@@ -321,7 +319,18 @@ class TLSClient:
|
||||
delay_seconds=delay,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
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)
|
||||
|
||||
def _discover_console_url(self) -> str:
|
||||
"""Look up the console URL from the services table."""
|
||||
|
||||
+108
-71
@@ -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
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
@@ -77,10 +77,12 @@ 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 (
|
||||
@@ -1290,6 +1292,100 @@ 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 (
|
||||
@@ -2215,74 +2311,6 @@ 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).
|
||||
|
||||
@@ -3865,6 +3893,8 @@ 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),
|
||||
]
|
||||
@@ -3879,8 +3909,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_workstream_title,
|
||||
set_title=set_workstream_title,
|
||||
refresh_title=refresh_title_handler, # lifted: shared body
|
||||
set_title=set_title_handler, # lifted: shared body
|
||||
send=send_handler, # lifted: shared body (P1.5)
|
||||
dequeue=dequeue_handler, # lifted (P1.5) — DELETE /send
|
||||
approve=approve_handler, # lifted: shared body
|
||||
@@ -3901,6 +3931,13 @@ 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(
|
||||
|
||||
@@ -1580,32 +1580,62 @@ 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",
|
||||
"/speech-to-text/stream",
|
||||
{ method: "POST", body: fd },
|
||||
)
|
||||
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
showToast(
|
||||
(res.body && res.body.error) || "Transcription failed",
|
||||
"error",
|
||||
);
|
||||
.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");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
// Drive the composer's auto-resize + send-enable listeners.
|
||||
this.inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.inputEl.focus();
|
||||
}
|
||||
if (got) {
|
||||
if (this.inputEl) this.inputEl.focus();
|
||||
voiceAnnounce("Transcript added to message.");
|
||||
} else {
|
||||
showToast("No speech detected", "error");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -1614,12 +1644,7 @@ class Pane {
|
||||
"error",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this._micBtn && !this._micDenied) {
|
||||
this._micBtn.disabled = !!this.busy;
|
||||
this._micBtn.classList.remove("is-busy");
|
||||
}
|
||||
});
|
||||
.finally(resetMic);
|
||||
}
|
||||
|
||||
_addTtsAction(el) {
|
||||
|
||||
@@ -837,6 +837,11 @@ 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();
|
||||
},
|
||||
|
||||
@@ -172,7 +172,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.109.1"
|
||||
version = "0.108.0"
|
||||
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/54/0b/ce24a4f275573f5e436ca954faca60c759d58ed152b8fa36a1e3b888e261/anthropic-0.109.1.tar.gz", hash = "sha256:83e06b3d9d40ff5898f588020e0cc4e42187de954549a3b5fbe6e2685a09c785", size = 927569, upload-time = "2026-06-09T23:55:24.884Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1419,7 +1419,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.41.1"
|
||||
version = "2.41.0"
|
||||
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/40/36/4c926a91554483977608951360c18c2e911592785eb87a6437813f6123f7/openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57", size = 783584, upload-time = "2026-06-10T16:10:37.667Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1924,7 +1924,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.0"
|
||||
version = "9.0.3"
|
||||
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/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2224,27 +2224,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.17"
|
||||
version = "0.15.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2425,19 +2425,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.68.2"
|
||||
version = "4.68.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "1.7.0a2"
|
||||
version = "1.6.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
||||
Reference in New Issue
Block a user