Compare commits

...

17 Commits

Author SHA1 Message Date
Patrick Buckley 8e11929ba0 chore: bump version to 1.6.1 2026-06-11 14:09:31 -07:00
Patrick Buckley d9e9a41b17 test(console): make dedupe-pin slice bounds reformat-tolerant
Review feedback: the next-case end markers were exact-indentation
string finds that raised a bare ValueError when unmatched. Use
whitespace-tolerant regexes with actionable assertion messages, and
bound the history-replay window structurally (next role branch, with
a generous fallback) instead of a fixed 600 chars.
2026-06-11 14:05:19 -07:00
Patrick Buckley 848b2cc1fb fix(ui): single-path Enter activation + hls.js teardown on player error
Review feedback: (1) the Enter keydown re-dispatched through btn.click(),
relying on the disabled-guard to suppress the browser's own
Enter-to-click — preventDefault + direct activation makes the keyboard
path provably single-fire; (2) the branch-scoped Hls instance was
unreachable from the media error handler, leaking its listeners and
loader timers when the player node was replaced with the retry UI —
hoist the ref and destroy it before replacement.
2026-06-11 14:05:19 -07:00
Patrick Buckley 1946002618 fix(ui): lift media player activation into the shared interactive pane
The interactive Pane renders media embeds (buildMediaEmbed / buildPlayButton),
but the Play activation — _loadHls / _isHlsUrl / _activatePlayer and the
click/keydown delegate — stayed behind in the standalone ui/static/app.js as
DOCUMENT-level listeners. The console L-shell mounts the same interactive.js
module but never loads ui/static/app.js, so the Play button was dead in
console-hosted interactive panes.

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

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

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

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

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

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

Touches are best-effort through the facade, which already swallows
storage errors, so a failed touch never breaks composition or a tool
call.
2026-06-11 14:05:18 -07:00
Patrick Buckley 23007e3ac5 chore: bump version to 1.6.0 2026-06-10 22:21:03 -07:00
Patrick Buckley f44886a55f docs: 1.6.0 changelog + release-track policy (#653)
* docs: 1.6.0 changelog — roll up the 1.5→1.6 line for stable

Replaces [Unreleased] with the 1.6.0 section: 320 main-only commits
since the stable/1.5 divergence grouped into theme bullets (license,
trajectory/migration-060, web search, rerank/memory, approvals/judge,
L-shell, shelf, SSE, providers, cluster ops, security). Breaking
changes aggregated up top; migration-060 backup callout reshaped from
discussion #631 for the stable audience.

* docs: add the stable/1.6 track to the changelog preamble

* docs: retire the stable/1.4 track — current + one prior policy

Changelog preamble down to three tracks with the policy stated;
1.4 retirement noted in the 1.6.0 Removed section (final release
v1.4.0; tags/artifacts remain, BUSL-1.1 as shipped). releasing.md
track table, policy bullet, and examples brought up to the 1.6.0
promote cycle — the doc was still describing the 1.4-stable era.
2026-06-10 22:19:58 -07:00
Patrick Buckley 3803feb008 fix(console): uniform not_found wait-entry keys + ws_ids param precision
PR #652 review follow-ups:
- not_found snapshot entries now carry the full key set (updated/name
  empty) so results[ws_id] is shape-uniform across states; pinned by a
  key-set assertion in the sentinel test
- ws_ids param text now distinguishes malformed (fails before any
  waiting) from well-formed-but-unobservable (first-tick abort) at
  unchanged length — per-param descriptions stay lean by policy
2026-06-10 22:03:08 -07:00
Patrick Buckley d0e9aa3dbe fix(console): coordinator ws-ref validation, did-you-mean recovery, wait fail-fast
Field incident: the coordinator LLM hand-copied a child ws_id and
collapsed its aaa run to a, producing a 30-char id. inspect said "not
found", wait called it "denied", neither offered recovery, and the model
concluded the child was dead and dropped the lane — silent report
degradation while the child kept working.

- validate model-supplied ws_id args at the tool boundary
  (send/close/cancel/delete/inspect/wait): full 32-hex ids pass through
  at unchanged storage cost; a child's exact legacy id still resolves;
  anything else fails fast with a did-you-mean (capped Levenshtein <=3
  over the coord's own children) plus a child roster. Near-misses never
  auto-resolve; display names are not addresses (mutable, non-unique) —
  a name ref errors with a pointer at the right id
- wait_for_workstream: rename per-entry state "denied" -> "not_found"
  with an honest sentinel; malformed refs error before any waiting
  (invalid_ws_ids); a well-formed id that is foreign, missing, or
  hard-deleted mid-wait aborts the wait on the tick that observes it
  instead of burning the timeout (mode=all was unsatisfiable) or riding
  along to complete=True (silent lane loss); mode=all completes only
  when every id is real-terminal; entries carry the child display name
- one not-found payload across all verbs: foreign and nonexistent stay
  byte-identical (no existence oracle), hints reference only the coord's
  own children, echoed refs clipped in error strings; invalid_ws_ids and
  not_found share one per-ref shape with the roster hoisted top-level
- inspect ownership now requires user_id parity via _row_in_own_subtree,
  matching the wait/mutating gates (#506) — closes the forged-parent
  cross-tenant read
- session exec serializes the structured recovery payload (results +
  did_you_mean + children) on unresolvable-id wait errors instead of
  collapsing to the bare error string
- tool JSON descriptions + coordinator docs updated to the new contract;
  incident regression test pins the captured aaa-collapse ids
2026-06-10 22:03:08 -07:00
Patrick Buckley 81a3eaecce chore: relicense BUSL-1.1 → Apache 2.0 for 1.6.0 (#651)
* chore: relicense BUSL-1.1 -> Apache 2.0 for 1.6.0

Flips every license artifact in the tree; 1.5.x and earlier remain
BUSL-1.1 per their release-time LICENSE files. Contributor consent
record: #548 (rationale: #546).

- LICENSE: canonical Apache 2.0 text
- NOTICE: new; copyright line + pointer to THIRD-PARTY-NOTICES
- pyproject.toml: SPDX expression + explicit license-files trio
- Dockerfile: COPY the license trio (hatchling needs them at build)
- THIRD-PARTY-NOTICES: BUSL line reworded; bundled-version drift
  fixed (KaTeX 0.17.0, Mermaid 11.15.0, hls.js 1.6.16)
- README badge + License section, CONTRIBUTING inbound-license line,
  TS SDK package(+lock), example pyproject
- docs/pgbouncer.md: drop stray ':' introduced in #353

* docs: add CONTRIBUTORS.md

* chore: drop LICENSE leading blank line

The apache.org LICENSE-2.0.txt begins with a newline; the SPDX
canonical text and GitHub license templates do not. Use the
conventional form — detection is whitespace-normalized either way.
2026-06-10 21:03:16 -07:00
Patrick Buckley 9b75a12848 fix(server): re-author --skip-permissions CLI flag wiring
Independent re-implementation of the --skip-permissions argparse flag,
OR-ed with the tools.skip_permissions config-store setting at both
consumption sites. Written from the flag's pre-existing spec (the
--help epilog and compose.yaml, which referenced it before #450
existed).

Replaces reverted #450 so that 1.6.0 ships no non-consented
contributions under Apache 2.0. Provenance record in #548.
2026-06-10 20:37:44 -07:00
Patrick Buckley 695a335744 Revert "fix(server): accept --skip-permissions CLI flag (#450)"
This reverts commit 2cdf87b115.
2026-06-10 20:37:44 -07:00
Patrick Buckley 9e0c86e78a fix(console): persona-btn radius onto the r-sm token
Copilot round: the 3px literal (carried from the hatch .seg segments)
disagreed with the shared :focus-visible rule, which restates
border-radius as var(--r-sm) — so the corner radius popped on keyboard
focus. One token, no jump.
2026-06-10 20:11:45 -07:00
Patrick Buckley c15dcee1f5 ui(console): launcher persona toggle wears its kind — amber coord / cyan int
The dashboard launcher's Coordinator|Interactive radiogroup styled its
active option as a neutral panel highlight — two faint text links that
said nothing about WHAT was being chosen. The active option now takes
the kind vocabulary the rest of the shell already speaks (.ptag.coord
amber / .ptag.int cyan): 15% kind tint, kind-colored label, and a kind
LED dot, in a recessed .seg-style track. Colour is never alone — the
LED + label weight carry the state, and the JS contract (classList
toggle on .active, aria-checked, roving tabindex) is untouched.
2026-06-10 20:11:45 -07:00
Patrick Buckley b3c3acc5d1 fix(scripts): livepass dialog-tier riders + loud open-failure + dock-displacement probe
Designer-review round on the scroll fix found the harness's dialog-tier
gate silently green: confirm-dialog (and install/coord-delete) markup
lives OUTSIDE #admin-layout, so the fragment extraction never embedded
it — ?open=confirm threw at showConfirmModal and screenshot a normal,
dialog-less page. build() now injects every hatch dialog the fragment
does not already contain, and a driven ?open= that ends with no open
dialog stamps OPEN-FAILED-<state> into the title instead of passing.

Also upstreams the review's probe states: &focuslast=1 focuses the last
shelf-body control (the displaced-dock regression class — only .sh-body
may scroll; head/foot must stay pinned) and &scrolled=bottom shows the
24px scroll tail.
2026-06-10 18:51:31 -07:00
Patrick Buckley 6bb47cad2d fix(console): manage-pane scroll regressions — interior scroller, clip the hatch-host, anchor hidden inputs
The L-shell height-pins the admin chain and .hatch-host clipped it, so no
box below the pane could scroll: tabs taller than the pane were cut dead,
and the overflow:hidden host doubled as a hidden scroll container that
focus-into-view silently scrolled — visually-hidden toggle/cap/radio
inputs escape the .sh-body scroller (abspos under an unpositioned label),
overhang the shelf, and a Tab keypress shoved the docked hatch off its
head with no scrollbar to recover by.

- .admin-content becomes the manage pane's interior scroller (the #main
  precedent); switchAdminTab resets it on real tab changes only
- .hatch-host: overflow hidden -> clip — paint clipping without a scroll
  container, so focus can never displace the dock
- position:relative anchors on the three hidden-input labels
  (toggle-switch, .sh-body .cap, segmented-option); .settings-toggle
  already carried one
- livepass: the console harness wraps the fragment in the REAL L-shell
  chain (its bespoke height pin is exactly how this bug class stayed
  invisible to the screenshot gates) and gains a ?tall=1/&scrolled=1
  scroll state
2026-06-10 18:51:31 -07:00
40 changed files with 2006 additions and 502 deletions
+200 -58
View File
@@ -6,81 +6,223 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) for
version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
Three release tracks are maintained:
Three release tracks are maintained — the current stable, one prior
stable, and the experimental line:
- **`stable/1.4`** — patch-only (`v1.4.x`)
- **`stable/1.5`** — patch-only (`v1.5.x`)
- **`stable/1.6`** — patch-only (`v1.6.x`)
- **`main`** — experimental (next major)
## [Unreleased]
## [1.6.0]
The first stable release of the 1.6 line — and the first under Apache 2.0.
> **⚠️ Before upgrading from 1.5.x:** 1.6.0 changes the internal
> conversation storage schema (Alembic migration `060`, applied
> automatically on first start). The migration converts existing
> workstreams and attachments in place — **back up your storage before
> upgrading** (`pg_dump` for PostgreSQL; copy the database file for
> SQLite). Background: discussion
> [#631](https://github.com/turnstonelabs/turnstone/discussions/631).
**Breaking changes at a glance** (details in the sections below):
`web_search` backend overhaul (Tavily/DuckDuckGo removed, `topic`
`category`), the `man` / `math` / `plan_agent` built-in tools and the
plan-review protocol removed, and the body-keyed `/v1/api/command`
endpoint replaced by path-keyed workstream verbs.
### License
- **Relicensed to Apache 2.0** — from BUSL-1.1, effective with this
release (#546, contributor assent record in #548). Versions 1.5.x and
earlier remain under BUSL-1.1 as shipped, and the `stable/1.5` branch
keeps its original LICENSE. New `NOTICE` and
`CONTRIBUTORS.md` files; `THIRD-PARTY-NOTICES` refreshed to match the
bundled library versions.
### Added
- **Self-hosted SearxNG web search** — the `web_search` tool's backend for
local/vLLM models is now a bundled [SearxNG](https://searxng.org) service
(`searxng` in both compose stacks; internal docker network only, JSON API
enabled, rate limiter off). Two new settings configure it: `tools.searxng_url`
(default `http://searxng:8080`, env `TURNSTONE_SEARXNG_URL`) and
`tools.searxng_engines` (env `TURNSTONE_SEARXNG_ENGINES`). Commercial providers
(Anthropic, OpenAI) continue to use their own native server-side search and
never touch SearxNG; the `mcp:server:tool` backend is unchanged. A persistent
`searxng-cache` volume keeps its favicon/internal cache across restarts, and
Caddy can serve SearxNG's own web UI on a dedicated port (dev stack:
`https://localhost:8444`, localhost-only; production: opt-in). See
[docs/docker.md](docs/docker.md) for the AGPL-3.0 §13 note that applies to
operators who expose the bundled SearxNG publicly.
- **`turnstone-admin` reads `config.toml`** — the admin CLI now honors
the same `[database]` section that `turnstone-server` does, with the
same precedence (`CLI / config.toml > TURNSTONE_DB_* env > defaults`).
Operators with DB credentials in `config.toml` no longer need to
re-export `TURNSTONE_DB_URL` before every admin invocation. Newly
plumbed through to `init_storage`: `pool_size`, `sslmode`,
`sslrootcert`, `sslcert`, `sslkey` — previously the admin CLI
silently dropped these. A new `--config PATH` flag mirrors the
one already on `turnstone-server`.
- **Mid-conversation system messages** — advisories, watch results,
skill hints, and operator interjections are now first-class
`role=system` turns in the trajectory instead of ad-hoc reminder
envelopes. Models with native mid-conversation system support receive
them verbatim; for everything else they fold into a nonce-fenced
wrapper. The one-shot `_reminders` side-channel is gone.
- **Self-hosted SearxNG web search** — the `web_search` backend for
local/vLLM models is now a bundled [SearxNG](https://searxng.org)
service (in both compose stacks; internal network only). Configure via
`tools.searxng_url` / `tools.searxng_engines`. Commercial providers
keep their native server-side search; the model can target a corpus by
passing `category` (`general`, `news`, `it`, `science`). Operators
exposing the bundled SearxNG publicly: see the AGPL-3.0 §13 note in
[docs/docker.md](docs/docker.md).
- **Endpoint-backed reranking** — a reranker is now a per-model
definition (Cohere/Jina-compatible wire: vLLM, TEI, llama.cpp, or a
commercial endpoint), disabled by default. When configured it scores
`web_search` results and the BM25 retrieval surfaces (deferred tools,
skills, memory) behind a `tools.rerank_bm25` toggle with a relevance
floor; a calibration CLI (and calibrate-on-detect) tunes the floor
per model.
- **Proactive memory relevance** — injected memories are selected by
BM25 + reranker against the recent user messages instead of recency
alone, and first composition defers to the first user turn so fresh
sessions select against a real query.
- **Smart Approvals** — opt-in (default off): high-confidence `approve`
verdicts from the intent judge auto-approve the tool call instead of
waiting for a human, with a confidence threshold and verdict
bookkeeping designed so a denied or reset judge never auto-fires.
- **Early-painted tool calls** — committed tool calls render immediately
as pending cards (both UIs upgrade the card in place by `call_id`)
instead of waiting for the judge verdict, so big parallel batches no
longer sit invisible during judging.
- **Voice I/O v1** — speech-to-text and text-to-speech as model roles
speaking the OpenAI audio wire protocol (#618); the interactive
composer grows a mic button.
- **Rewind / retry / edit-first-message** — full UX in both the
interactive UI and the coordinator pane, backed by shared path-keyed
verb handlers (#549).
- **Workstream export** — download a conversation as OpenAI-format
messages JSON.
- **Skills platform round** — `SKILL.md` ingestion learns
`when_to_use` / `model` / `effort` / `paths`; prompt substitution
supports `$ARGUMENTS`, `$N`, `$<name>`, and `${CLAUDE_*}` (#572);
per-skill `disable-model-invocation` and `user-invocable` flags
(#571); `skill` + `list_skills` unify into one dual-kind tool; new
`model.skills.write` permission.
- **Coordinator hardening for small models** — workstream references in
coordinator tool calls are validated with did-you-mean recovery, and
`wait_for_workstream` fails fast with uniform `not_found` entries
instead of hanging on a hallucinated `ws_id`.
- **Provider support** — Claude Fable 5 and Claude Opus 4.8; xAI/Grok
via the OpenAI Responses lane; vLLM reasoning-field replay completes
the reasoning-persistence work (#537).
- **Cluster-by-default deployment** — the compose stack fronts
everything with Caddy and supports bare-metal node join; a one-line
`curl | bash` installer bootstraps a node; nodes with no configured
models boot into a degraded state instead of crash-looping; channel
gateways stand by when no adapter token is set.
- **MCP OAuth tokens encrypted at rest**.
- **`turnstone-admin` reads `config.toml`** — same `[database]` section
and precedence as the server (`CLI / config.toml > TURNSTONE_DB_* env
> defaults`), including `pool_size` and the `ssl*` knobs it previously
dropped; new `--config PATH` flag.
### Changed
- **Conversation storage and the provider wire are rebuilt around a
canonical trajectory** (migration `060` — see the upgrade note).
Internally a conversation is now a provider-neutral `Turn` sequence
lowered to each provider's wire format at send time; provider-specific
tool-call metadata rides an opaque producer-tagged lane (replayed
verbatim to the producing provider, rebuilt for others); attachments
become content-addressed, reference-counted rows resolved at the
provider boundary; orphan tool-call repair happens once, at send time.
Wire-visible behavior is unchanged for OpenAI-compatible providers;
histories are preserved across the migration.
- **The console and web UI share one L-shell** — a left glyph rail, a
tab bar, and a pane host now frame interactive chats, coordinator
sessions, dashboards, and the admin panel as tabs in a single window;
the standalone web UI adopts the same shell and the old split-pane
layout is retired. Coordinator and interactive conversations render
through shared `.conv-*` card builders, the rail collapses to a glyph
strip (remembered per browser), mobile gets an off-canvas drawer, and
the frontend is now ES modules end to end.
- **Admin panel modals → the Service Hatch shelf** — all ~35 admin
modals are replaced by pane-scoped shelves plus a small dialog tier
for confirmations. Schedules gain a cron builder with a next-3-runs
preview endpoint, model capabilities render as an LED tile matrix, and
the legacy modal machinery is deleted.
- **SSE delivery is resumable end to end** — per-workstream ring buffer
with `Last-Event-ID` replay (cap raised 2,000 → 50,000), fresh-connect
and reconnect unified on one event-id cursor (in-flight tool batches
included), persisted `last_error` replays on connect, the console
proxy forwards `Last-Event-ID`, and panes close their connections on
`beforeunload` to stop multi-pane refresh from exhausting the
browser's per-host connection cap (#539).
- **Workstream verbs are path-keyed** *(BREAKING)*`rewind` / `retry`
/ `edit-first-message` live at
`/v1/api/workstreams/{ws_id}/<verb>` alongside the other session
verbs; the body-keyed `/v1/api/command` endpoint is removed (#549).
- **`/history` is projected server-side** — both UIs consume the same
REST-first wire shape instead of re-deriving it client-side.
- **Saved workstreams & coordinators: card grid → sortable table** with
model/skill/context columns, pagination, and a unified selector across
both dashboards.
- **`tools.web_search_backend` accepted values** *(BREAKING)* — now `""`
(auto), `"searxng"`, or `"mcp:server:tool"`. The old `"tavily"` and `"ddg"`
values are gone; a config still set to either disables web search and logs a
warning. Auto-detect resolves to SearxNG when `searxng_url` is set, otherwise
no client (the `web_search` tool is dropped for models without native search).
- **`web_search` tool: `topic``category`** *(BREAKING)*the LLM-facing
parameter is renamed and its values are now `general` (default), `news`, `it`
(code/tech), or `science`, mapped to SearxNG categories so the model can target
the right corpus. The Tavily-era `finance` topic (no SearxNG equivalent) is gone.
(auto), `"searxng"`, or `"mcp:server:tool"`. The old `"tavily"` and
`"ddg"` values are gone; a config still set to either disables web
search and logs a warning. Auto-detect resolves to SearxNG when
`searxng_url` is set.
- **`web_search` tool: `topic``category`** *(BREAKING)*renamed
LLM-facing parameter; values map to SearxNG categories. The Tavily-era
`finance` topic is gone.
- **Core install includes what most deployments use** — `anthropic`,
`postgres`, `console`, and `tls` are core dependencies rather than
extras.
- **NODES table → bottom-bar node picker** in the console.
### Fixed
- **Cluster mTLS actually survives operations** — certificate identity
keys on the advertised host rather than the container ID, renewals are
scoped per node, reloaded certs hot-swap into the live SSL context,
and healthchecks/boot retries are mTLS-aware.
- **Intent-verdict lifecycle** — history replay ships risk-none verdict
rows (live/replay parity), late verdicts persist as `superseded` for
the audit trail instead of vanishing, bulk verdict insert tolerates
per-row conflicts, and cancel-on-approval honors its run-to-completion
contract.
- **Usage accounting** — dashboard totals were under-counting; auxiliary
LLM spend (judge, rerank, memory) is now recorded.
- **Concurrent first-boot migrations** no longer deadlock on the
advisory lock.
- **Output renderer** — single-`$` inline math no longer false-positives
in prose; `strip_html` preserves block structure and drops a ReDoS
risk.
- **Model registry** orders versions numerically (no more `1.10 < 1.9`
selection).
### Removed
- **Tavily and DuckDuckGo `web_search` backends** *(BREAKING)* replaced by the
bundled self-hosted SearxNG service (see Added). Removed: the
`tools.tavily_api_key` setting, the `$TAVILY_API_KEY` env var, the
`[api].tavily_key` config key, and the `ddg` install extra (the `ddgs`
dependency). Migration: use the bundled SearxNG (it ships in the compose stacks
by default) or point `TURNSTONE_SEARXNG_URL` at an existing instance. No
database migration required.
- **`man`, `math`, and `plan_agent` built-in tools removed** — `man` and
`math` duplicated capabilities already available through `bash`; `plan_agent`
is better expressed as a `task_agent` running a planning skill. Removing
them simplifies the tool surface and cuts per-call token cost. This release
also removes: the `math` sandbox executor (`turnstone.core.sandbox`) and the
`[sandbox]` extra's role for it; the read-only `AGENT_TOOLS` sub-agent tool
set and the `agent` tool-metadata key; the plan-review protocol
(`/v1/api/plan` endpoint, `plan_review`/`plan_resolved` SSE events, the
`on_plan_review` SDK/UI hook); and the `model.plan_alias` /
`model.plan_effort` ConfigStore settings (and the corresponding
`[model].plan_model` / `[model].plan_effort` config.toml knobs).
**Breaking change** on the experimental 1.6 line. Interactive built-in tool
count moves from 19 → 16; `TASK_AGENT_TOOLS` from 13 → 11.
- **Tavily and DuckDuckGo `web_search` backends** *(BREAKING)*
replaced by the bundled SearxNG service. Removed:
`tools.tavily_api_key`, `$TAVILY_API_KEY`, `[api].tavily_key`, and the
`ddg` install extra. Point `TURNSTONE_SEARXNG_URL` at an existing
instance or use the bundled one; no database migration required.
- **`man`, `math`, and `plan_agent` built-in tools** *(BREAKING)*
`man`/`math` duplicated `bash`; planning is better expressed as a
`task_agent` running a planning skill. Also removed: the `math`
sandbox executor, the read-only `AGENT_TOOLS` sub-agent set, the
plan-review protocol (`/v1/api/plan`, `plan_review`/`plan_resolved`
SSE events, `on_plan_review` hooks), and the `model.plan_*` settings.
Interactive built-in tool count: 19 → 16.
- **`stable/1.4` track retired** — the maintenance policy is now the
current stable plus one prior (`stable/1.6` + `stable/1.5` as of this
release). 1.4's final release was `v1.4.0`; its tags and released
artifacts remain available, under BUSL-1.1 as shipped.
### Security
- **Permissive `config.toml` now warns** — `turnstone.core.config.load_config`
logs a single warning when the resolved config file is group- or
world-readable (any bit in `0o077`). DB password and TLS key paths
live in `[database]`; operators usually want the file at `0600`.
- **Zero direct-HTML frontend** — every `innerHTML` sink across the
console and web UI is replaced with DOM construction or `setSafeHtml`,
inline handlers became delegated bindings, and CI lints pin the
invariant (plus `var`-free and const-reassign checks) across all
swept bundles.
- **Output guard grows an LLM stage** — merged with the heuristics as
escalate-only (an LLM verdict can raise but never lower a heuristic
positive), with annotated findings, a capability gate, and hardening
against domain-camouflaged injection (#560, #573).
- **One trust-fence primitive** — operator and judge envelopes share a
nonce-fenced wrapper (64-bit nonces, host-escaping); the output guard
flags nonce forgery, and skill hints no longer echo model-controlled
filter values into trusted text.
- **RBAC** — built-in role overrides get an editor, and several
under-enforced permission gates are tightened (#585).
- **Permissive `config.toml` warns** — a single startup warning when the
resolved config file is group- or world-readable; operators usually
want `0600`.
- **Dependency floors** — `starlette>=1.0.1` (PYSEC-2026-161 host-header
path injection) and `aiohttp>=3.14.0` (security release).
## [1.5.17]
+1 -1
View File
@@ -56,4 +56,4 @@ Open an issue at https://github.com/turnstonelabs/turnstone/issues with:
## License
By contributing, you agree that your contributions will be licensed under the
project's [Business Source License 1.1](LICENSE).
project's [Apache License 2.0](LICENSE).
+13
View File
@@ -0,0 +1,13 @@
# Contributors
Turnstone is written and maintained by Patrick Buckley
([@eous](https://github.com/eous)).
The following people have contributed code to the project — thank you:
- Burhan ([@Burhan-Q](https://github.com/Burhan-Q))
- chrismuzyn ([@chrismuzyn](https://github.com/chrismuzyn))
- daoxley ([@daoxley](https://github.com/daoxley))
- Robert DeAngelis ([@OriginalOrangeXD](https://github.com/OriginalOrangeXD))
- William ([@sillyWillieBilly](https://github.com/sillyWillieBilly))
- [@pizzaandcheese](https://github.com/pizzaandcheese)
+1 -1
View File
@@ -33,7 +33,7 @@ RUN useradd --create-home --shell /bin/bash turnstone
WORKDIR /app
# Install dependencies first (cached layer — only re-runs when deps change)
COPY pyproject.toml uv.lock README.md LICENSE ./
COPY pyproject.toml uv.lock README.md LICENSE NOTICE THIRD-PARTY-NOTICES ./
RUN uv sync --frozen --no-install-project --no-dev \
--no-compile --extra all
+187 -48
View File
@@ -1,62 +1,201 @@
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Parameters
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Licensor: Patrick Buckley
Licensed Work: Turnstone 0.2.0. The Licensed Work is (c) 2025-2026 Patrick Buckley.
Additional Use Grant: You may make production use of the Licensed Work, provided
your use does not include providing the Licensed Work to third
parties as a hosted or managed service, where the service
provides users with access to any substantial set of the
features or functionality of the Licensed Work.
Change Date: 2030-03-01
Change License: Apache License, Version 2.0
1. Definitions.
For information about alternative licensing arrangements for the Licensed Work,
please contact buckleypm@gmail.com.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
Notice
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
Business Source License 1.1
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
Terms
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited production use.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+7
View File
@@ -0,0 +1,7 @@
Turnstone
Copyright 2025-2026 Patrick Buckley
Licensed under the Apache License, Version 2.0; see the LICENSE file.
Third-party software bundled with this distribution is listed in the
THIRD-PARTY-NOTICES file; each component remains under its own license.
+2 -2
View File
@@ -3,7 +3,7 @@
[![CI](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml/badge.svg)](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/turnstone)](https://pypi.org/project/turnstone/)
[![Python](https://img.shields.io/pypi/pyversions/turnstone)](https://pypi.org/project/turnstone/)
[![License](https://img.shields.io/badge/license-BSL--1.1-blue)](LICENSE)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/Nh3bWMacaq)
Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real tools — shell, files, search, web — and run them across your own cluster with direct HTTP routing and interactive interfaces. Your code, your models, your data stay on hardware you control: no telemetry, no phone-home.
@@ -169,4 +169,4 @@ Questions, ideas, or want to show what you're building? Join us on Discord:
## License
[Business Source License 1.1](LICENSE) — free for all use except hosting as a managed service. Converts to Apache 2.0 on 2030-03-01.
[Apache License 2.0](LICENSE), as of version 1.6.0. Versions 1.5.x and earlier remain under the Business Source License 1.1 they shipped with.
+4 -4
View File
@@ -2,11 +2,11 @@ Turnstone — Third-Party Notices
This file contains the licenses and notices for third-party software bundled
with Turnstone. Each bundled dependency retains its original license; the
Turnstone BUSL-1.1 license does not apply to these components.
Turnstone Apache-2.0 license does not apply to these components.
================================================================================
KaTeX 0.16.38
KaTeX 0.17.0
https://katex.org/
https://github.com/KaTeX/KaTeX
@@ -70,7 +70,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
Mermaid 11.13.0
Mermaid 11.15.0
https://mermaid.js.org/
https://github.com/mermaid-js/mermaid
@@ -98,7 +98,7 @@ SOFTWARE.
================================================================================
hls.js 1.6.15
hls.js 1.6.16
https://github.com/video-dev/hls.js
Copyright 2017 Dailymotion
+12 -5
View File
@@ -237,14 +237,21 @@ Key properties:
tool with a fresh timeout.
- **Modes**`mode="any"` returns as soon as one child reaches a
real terminal state (`idle` / `error` / `closed` / `deleted`);
`mode="all"` waits for every polled child.
`mode="all"` waits for every polled child to reach a real
terminal state.
- **Progress throttling** — the poll loop runs every 500 ms but the
SSE emission is diff-on-state-change plus a 5-second heartbeat. A
600 s wait generates O(dozens) of progress events, not 1200.
- **Denied rows** — an id the caller doesn't own (cross-tenant) or a
missing row is reported as a `denied` state in the results dict;
`mode="any"` won't satisfy on a pure-denied list (the LLM should
treat it as a config error, not a completion).
- **Unresolvable ids** — ws_ids are validated up front (exactly
32 hex chars; copy them verbatim): a malformed id fails the call
immediately with did-you-mean suggestions and a roster of the
coord's children. An id the caller doesn't own, a missing row, or
a child hard-deleted mid-wait is reported as `state="not_found"`
and aborts the wait on the tick that observes it (top-level
`error` / `not_found` / `children` fields, `complete=false`) — the
LLM should fix the id and re-issue, not conclude the child died.
Foreign and missing collapse into one shape, so the wait can't be
used as an existence oracle.
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
loop — a wait consumes one assistant turn regardless of how long the
+25 -11
View File
@@ -168,19 +168,33 @@ Every ws_id returned by `spawn_workstream` / `spawn_batch` is a
invent ws_ids — a model that hallucinates `"child-1"` or `"ws-abc"`
hits the tenant guard in `CoordinatorClient._is_own_subtree`, which
validates ws_id against `parent_ws_id=coord_ws_id` AND
`user_id=owner` in storage. The rejection shape varies by tool:
`user_id=owner` in storage. The rejection shape is uniform and
recovery-oriented:
- **Mutating ops** (`send_to_workstream`, `close_workstream`,
`cancel_workstream`, `delete_workstream`) return
`{"error": "workstream not in coordinator subtree: <ws_id>", "status": 404}`
— the skill should treat this as a tool error, not an empty result.
- **`inspect_workstream`** returns `{"error": "workstream not found", "ws_id": "<ws_id>"}`
(same shape as a genuinely missing row, so the guard can't be
used as an existence oracle).
- **`wait_for_workstream`** reports the offending id with
`state="denied"` in its `results` dict; `mode="any"` won't
satisfy on a pure-denied list, so a hallucinated id won't trick
the wait into reporting "complete".
`cancel_workstream`, `delete_workstream`) and
**`inspect_workstream`** return
`{"error": "no workstream matching '<ref>' among your children; …",
"status": 404, "ws_id": "<ref>", "did_you_mean": [...],
"children": [...], "children_truncated": bool}` — a did-you-mean
(edit distance ≤ 3 against the coord's own children, which catches
the garbled-hex incident class: a 32-char id whose `aaa` run
collapsed to `a`) plus a roster of the coord's children. A ref
that matches a child's display NAME is called out explicitly with
the right id (names are mutable labels, not addresses). Foreign
and nonexistent ids produce the same payload (no existence
oracle), every hint references only the coord's own children, and
near-miss ids are never auto-resolved — the skill should fix the
id and re-issue, not treat the child as dead.
- **`wait_for_workstream`** validates ids before waiting: a
malformed id fails the whole call immediately (`invalid_ws_ids`
carries the per-id payloads above, `elapsed=0`); a well-formed id
that is foreign, nonexistent, or hard-deleted mid-wait surfaces as
`state="not_found"` and aborts the wait on that tick with
top-level `error` / `not_found` / `children` fields.
`complete=true` therefore means every polled lane really finished
— an unobservable id can neither burn the timeout nor ride along
to a "complete" result.
Pattern: capture each spawn result in the next tool call's input.
The JSON tool-result carries `{"child_ws_id": "...", "name": "...",
+1 -1
View File
@@ -108,7 +108,7 @@ pgbouncer:
maxClientConn: 5000
maxDbConnections: 80
```
:
---
## Configuration reference
+18 -17
View File
@@ -6,10 +6,9 @@ Turnstone ships several parallel release tracks from a single PyPI package.
| Track | Versions | Branch | Docker tags | PyPI install |
|-------|----------|--------|-------------|--------------|
| **Legacy 1.0** | `1.0.x` | `stable/1.0` | `:1.0.x`, `:1.0` | `pip install 'turnstone==1.0.*'` |
| **Stable 1.3** | `1.3.x` | `stable/1.3` | `:1.3.x`, `:1.3` | `pip install 'turnstone==1.3.*'` |
| **Stable 1.4** | `1.4.x` | `stable/1.4` | `:1.4.x`, `:1.4`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.5.0aN` | `main` | `:1.5.0aN`, `:experimental` | `pip install turnstone --pre` |
| **Stable 1.5** | `1.5.x` | `stable/1.5` | `:1.5.x`, `:1.5` | `pip install 'turnstone==1.5.*'` |
| **Stable 1.6** | `1.6.x` | `stable/1.6` | `:1.6.x`, `:1.6`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.7.0aN` | `main` | `:1.7.0aN`, `:experimental` | `pip install turnstone --pre` |
- **Stable** tracks receive bugfixes only. The most-recent stable minor
owns the `:stable` / `:latest` Docker tags and the default PyPI
@@ -17,8 +16,10 @@ Turnstone ships several parallel release tracks from a single PyPI package.
- **Experimental** (always on `main`) receives new features. May be
rough around the edges.
- When experimental matures, it is promoted to a new stable minor via
a `stable/X.Y` branch; older stable branches continue to receive
security fixes until explicitly retired.
a `stable/X.Y` branch. One prior stable track is maintained alongside
the current one; at each promotion the oldest track is retired — its
branch is deleted, while its tags and released artifacts remain
available.
## Version Scheme
@@ -33,17 +34,17 @@ Turnstone ships several parallel release tracks from a single PyPI package.
## Releasing an Experimental Version (from main)
```bash
scripts/release.sh 1.5.0a2 --push
scripts/release.sh 1.7.0a2 --push
```
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.5.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.7.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
## Releasing a Stable Patch (from stable/X.Y)
```bash
git checkout stable/1.4
git checkout stable/1.6
git cherry-pick <commit-hash> # bugfix from main
scripts/release.sh 1.4.1 --push
scripts/release.sh 1.6.1 --push
```
## Promoting Experimental to Stable
@@ -52,19 +53,19 @@ When `main` is ready for a stable release:
```bash
# 1. Tag the stable release on main
scripts/release.sh 1.5.0 --push
scripts/release.sh 1.6.0 --push
# 2. Create the stable maintenance branch from that tag
git branch stable/1.5 v1.5.0
git push origin stable/1.5
git branch stable/1.6 v1.6.0
git push origin stable/1.6
# 3. Start the next experimental cycle on main
scripts/release.sh 1.6.0a1 --push
scripts/release.sh 1.7.0a1 --push
```
The previous stable branch (`stable/1.4`) continues to receive
security-only patches; older tracks (`stable/1.0`, `stable/1.3`) are
retired when they fall out of support.
The previous stable branch continues to receive security-only patches;
the track before it is retired at each promotion (at 1.6.0:
`stable/1.5` stays maintained, `stable/1.4` is retired).
## CI/CD Pipeline
+1 -1
View File
@@ -7,7 +7,7 @@ name = "mcp-cluster-ops"
version = "0.1.0"
description = "MCP server for Turnstone cluster operations — reference implementation."
requires-python = ">=3.11"
license = "BUSL-1.1"
license = "Apache-2.0"
dependencies = [
"turnstone",
"mcp>=1.6",
+3 -2
View File
@@ -4,10 +4,11 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.6.0rc2"
version = "1.6.1"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
license = "Apache-2.0"
license-files = ["LICENSE", "NOTICE", "THIRD-PARTY-NOTICES"]
requires-python = ">=3.11"
authors = [{name = "Patrick Buckley", email = "buckleypm@gmail.com"}]
keywords = ["ai", "chat", "llm", "agent", "tools", "openai"]
+105 -15
View File
@@ -26,6 +26,17 @@ UI harness (?open=): new-ws · new-ws-fork · edit-title · delete-ws ·
Console harness (?open=): schedule-create · schedule-edit · model-create ·
model-edit · model-save (drives a Save click; document.title becomes
PUT-OK-<n> on success) · policy · confirm · token
Plus &tall=1 (90-row users panel the .admin-content scroll state; the
synthetic rows wrap to two lines, so judge overflow geometry, not row
cadence) · &scrolled=1 lands mid-list, &scrolled=bottom shows the 24px
scroll tail · &focuslast=1 focuses the last shelf-body control (the
displaced-dock regression probe: only .sh-body may scroll; head/foot stay
pinned). All combinable with ?open=. The console page wraps the fragment
in the REAL L-shell chain pane-pinned height, interior scroller so
scroll/dock geometry matches production; keep it that way. Body-level
dialogs (confirm/install/coord-delete) are injected as riders; a driven
?open= that ends with no open dialog stamps OPEN-FAILED-<state> into the
title instead of passing silently.
Governance surfaces (roles/HR/OGP/memory/skill) need fixtures that are not
canned yet add a fixture + driver branch below when you need one.
@@ -212,8 +223,9 @@ UI_TEMPLATE = """<!doctype html>
"""
# --------------------------------------------------------------------------
# Console harness — the admin pane fragment hosts the shelves; body-level
# hatch dialogs (confirm/token/install/batch) ride along inside it.
# Console harness — the admin pane fragment hosts the shelves (token-created
# included); dialog-tier markup outside the fragment (confirm/install/
# coord-delete) is injected via the RIDERS marker in build().
# model-save click-drives the submit: document.title flips to PUT-OK-<n>.
# --------------------------------------------------------------------------
CONSOLE_TEMPLATE = """<!doctype html>
@@ -222,19 +234,51 @@ CONSOLE_TEMPLATE = """<!doctype html>
<meta charset="utf-8" />
<title>console livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="console-static/style.css" />
<link rel="stylesheet" href="shared/shell.css" />
<link rel="stylesheet" href="shared/hatch.css" />
<style>
body { padding: 0; }
#view-admin { padding: 20px; height: 100vh; box-sizing: border-box; }
#admin-layout { height: 100%; }
</style>
</head>
<body>
<div id="view-admin">
<!-- FRAGMENT:BEGIN -->
<!-- FRAGMENT:END -->
<!-- The REAL L-shell chain (shell.js buildShell + pane.js DOM, verbatim
class names) so the harness inherits production scroll geometry:
.pane-body > #view-admin > .admin-layout height-pin the hatch-host
and .admin-content is the pane's interior scroller. Never replace
this with bespoke height overrides the clipped-pane / displaced-
shelf regressions were invisible to the harness precisely because
it used to pin #admin-layout with its own CSS. -->
<div class="app">
<aside class="rail" id="shell-rail">
<div class="rail-brand">
<button class="brand-home" type="button">
<div class="brand-mark"></div>
<span class="brand-name">turnstone</span>
<span class="brand-sub">console</span>
</button>
</div>
</aside>
<main class="content">
<div class="tabbar"></div>
<div class="panes">
<section class="pane">
<!-- no .pane-head: PaneManager._mount builds section.pane >
div.pane-body only -->
<div class="pane-body">
<div id="view-admin">
<!-- FRAGMENT:BEGIN -->
<!-- FRAGMENT:END -->
</div>
</div>
</section>
</div>
</main>
</div>
<!-- Body-level dialog tier (confirm / install / coord-delete): their
markup sits OUTSIDE #admin-layout in index.html, so the fragment
extraction misses them build() injects every hatch dialog the
fragment does not already contain. -->
<!-- RIDERS:BEGIN -->
<!-- RIDERS:END -->
<div id="toast" role="status" aria-live="polite"></div>
<script>
(function () {
@@ -331,6 +375,32 @@ CONSOLE_TEMPLATE = """<!doctype html>
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
var open = q.get("open") || "";
// ?tall=1 the scroll state: one panel visible with enough rows to
// overflow the pane, so a screenshot shows .admin-content scrolling
// (and a shelf staying docked above it). Mirrors switchAdminTab's
// one-panel-visible invariant without booting the tab loaders.
if (q.get("tall")) {
var panels = document.querySelectorAll(".admin-panel");
for (var i = 0; i < panels.length; i++)
panels[i].style.display =
panels[i].id === "admin-users" ? "" : "none";
// No fallback: a fragment rename must fail loudly, not misplace rows.
var rowHost = document.querySelector("#admin-users [role=list]");
rowHost.textContent = ""; // drop the static "Loading users…" stub
for (var r = 0; r < 90; r++) {
var row = document.createElement("div");
row.className = "admin-row"; // real row chrome geometry tracks production
row.textContent =
"user-" + String(r).padStart(3, "0") + " \\u00b7 synthetic row";
rowHost.appendChild(row);
}
var content = document.getElementById("admin-content");
if (content && q.get("scrolled"))
content.scrollTop =
q.get("scrolled") === "bottom"
? content.scrollHeight // the 24px scroll-tail state
: content.scrollHeight / 2; // land mid-list
}
setTimeout(function () {
if (open === "schedule-create") showCreateScheduleModal();
else if (open === "schedule-edit") showEditScheduleModal("t1");
@@ -361,6 +431,22 @@ CONSOLE_TEMPLATE = """<!doctype html>
var d = document.querySelector("dialog[open]");
if (d) window.TurnstoneHatch.setBusy(d, true);
}, 400);
// A driven state that ends with nothing open must fail LOUDLY in
// the screenshot pipeline, not render a quietly dialog-less page.
setTimeout(function () {
var top = document.querySelector("dialog[open]");
if (open && !top) document.title = "OPEN-FAILED-" + open;
// &focuslast=1 the displaced-dock regression probe: focus the
// last form control in the shelf BODY (the visually-hidden
// toggle/radio inputs live there). Only .sh-body may scroll;
// the head/foot strips must stay pinned in the screenshot.
if (top && q.get("focuslast")) {
var els = top.querySelectorAll(
".sh-body input, .sh-body select, .sh-body textarea",
);
if (els.length) els[els.length - 1].focus();
}
}, 600);
}, 150);
});
</script>
@@ -387,11 +473,15 @@ def build(out: Path) -> None:
symlink(con / "shared", ROOT / "turnstone/shared_static")
symlink(con / "console-static", ROOT / "turnstone/console/static")
(con / "livepass.html").write_text(
inject(CONSOLE_TEMPLATE, "FRAGMENT", extract_admin_fragment()),
encoding="utf-8",
)
print(f"{con}/livepass.html — admin fragment embedded")
frag = extract_admin_fragment()
# Dialog-tier markup living OUTSIDE #admin-layout (confirm, install,
# coord-delete) would otherwise be silently absent — and ?open=confirm
# would screenshot a dialog-less page while the gate stayed green.
riders = [b for b in extract_dialogs(CONSOLE_INDEX) if b not in frag]
page = inject(CONSOLE_TEMPLATE, "FRAGMENT", frag)
page = inject(page, "RIDERS", "\n".join(riders))
(con / "livepass.html").write_text(page, encoding="utf-8")
print(f"{con}/livepass.html — admin fragment + {len(riders)} rider dialogs")
def main() -> None:
+1 -1
View File
@@ -7,7 +7,7 @@
"": {
"name": "@turnstone/sdk",
"version": "0.4.0",
"license": "BUSL-1.1",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^6.0.0",
"vitest": "^4.1"
+1 -1
View File
@@ -30,7 +30,7 @@
"sdk",
"client"
],
"license": "BUSL-1.1",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^6.0.0",
"vitest": "^4.1"
+52
View File
@@ -285,6 +285,38 @@ def test_system_turn_dedups_against_history_by_event_id() -> None:
"replayHistory (and the live handler) must record system-turn ids for the dedup set."
)
# Pin the wiring on BOTH read paths, scoped to its method — a refactor that
# keeps the Set but drops the live-handler consultation (or the
# replayHistory-side record) silently re-opens the double-render while the
# file-global checks above still pass.
live_start = body.index('case "system_turn":')
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
# path breaks before the ``.add(``, so a ``break;``-bounded slice would
# drop the record half and false-fail the ``.add(`` assertion below.
# Whitespace-tolerant so a reformat can't silently break the bound.
next_case = re.search(r'\n\s*case "', body[live_start + 1 :])
assert next_case, (
"no switch case found after system_turn to bound the pin slice — if "
"system_turn became the last case, re-anchor this pin's end marker."
)
live_block = body[live_start : live_start + 1 + next_case.start()]
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*has\(", live_block), (
"the live system_turn handler must CONSULT the dedup set (skip an id "
"already painted from /history), not merely reference the Set elsewhere."
)
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", live_block), (
"the live system_turn handler must RECORD the id it renders so a later "
"/history re-render (clear_ui) doesn't repaint it."
)
replay_start = _pane_method_offset(body, "replayHistory")
replay_end = _pane_method_offset(body, "_attachRetryToLastAssistant")
replay_block = body[replay_start:replay_end]
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", replay_block), (
"replayHistory must record each replayed system row's event_id so the "
"live system_turn handler can dedup against it."
)
def test_retry_walk_skips_operator_context_cards() -> None:
"""Interactive twin of the coord retry-skip guard.
@@ -416,6 +448,26 @@ def test_phase8_mcp_error_helpers_defined() -> None:
)
def test_media_player_activation_not_duplicated_in_standalone() -> None:
"""The media-player activation (``_loadHls`` / ``_activatePlayer`` + the
click/keydown delegate) moved into the shared interactive pane so BOTH the
standalone server and the console activate the Play button. The standalone
app.js must NOT keep its own copy a duplicate document-level listener
would double-fire on the standalone (two players swapped in) while the lift
is what fixed the console (where app.js was never the host). Pin the
standalone clean so the stale copy can't drift back in."""
app = _APP_JS.read_text(encoding="utf-8")
for name in ("_loadHls", "_activatePlayer", "_isHlsUrl", "media-play-btn"):
assert name not in app, (
f"standalone app.js must not re-declare the lifted media player "
f"({name!r}) — it lives in shared_static/interactive.js now"
)
# The lift target carries the real implementation (the click delegate too).
inter = _INTERACTIVE_JS.read_text(encoding="utf-8")
assert "function _activatePlayer(" in inter
assert "activateMediaPlayButton(btn)" in inter
def test_phase8_settings_panel_handlers_defined() -> None:
"""The settings modal exposes four entry points that the inline
``onclick`` attributes in index.html depend on. Renaming or
+332 -75
View File
@@ -9,6 +9,7 @@ storage-call path.
from __future__ import annotations
import json
import threading
import time
from typing import TYPE_CHECKING, Any
@@ -405,7 +406,10 @@ def test_mutating_ops_reject_foreign_ws_id_without_hitting_proxy():
]:
result = call("ws-foreign", **kwargs) # type: ignore[arg-type]
assert result["status"] == 404
assert "not in coordinator subtree" in result["error"]
assert "no workstream matching" in result["error"]
# Recovery payload: a roster of the coord's own children rides
# along so a garbled id is fixable in one round-trip.
assert {c["ws_id"] for c in result["children"]} == {"ws-x", "ws-y"}
# No HTTP requests issued — guard rejected before _post.
assert captured == []
@@ -421,6 +425,56 @@ def test_mutating_ops_accept_self_ws_id():
assert captured[0].url.path == "/v1/api/route/workstreams/coord-1/send"
def test_mutating_ops_reject_foreign_hex_id_with_recovery_payload():
"""A well-formed 32-hex id that isn't ours passes format validation
and dies on the ownership guard with the SAME recovery payload as a
malformed ref uniform shape, no existence oracle, no HTTP."""
client, captured = _mock_client(_ok_json({"status": 200}))
result = client.send("f" * 32, "hi")
assert result["status"] == 404
assert "no workstream matching" in result["error"]
assert {c["ws_id"] for c in result["children"]} == {"ws-x", "ws-y"}
assert captured == []
def test_mutating_ops_reject_child_name_with_id_pointer(tmp_path):
"""A model that pastes a child's display NAME instead of its id is
pointed straight at the right ws_id names are mutable, non-unique
labels (the title generator can rewrite what the operator sees), so
they are deliberately NOT addresses and nothing resolves silently."""
st = SQLiteBackend(str(tmp_path / "names.db"))
st.register_workstream("coord-1", kind="coordinator", user_id="user-1")
real = "7c61eafe470c54caaa89490a4b9c0f7d"
st.register_workstream(
real,
kind="interactive",
parent_ws_id="coord-1",
state="running",
user_id="user-1",
name="minisforum-research",
)
captured: list[httpx.Request] = []
def _trap(req: httpx.Request) -> httpx.Response:
captured.append(req)
return httpx.Response(200, json={})
client = CoordinatorClient(
console_base_url="http://console",
storage=st,
token_factory=lambda: "t",
coord_ws_id="coord-1",
user_id="user-1",
http_client=httpx.Client(transport=httpx.MockTransport(_trap)),
child_event_bus=ChildEventBus(),
)
result = client.send("minisforum-research", "status?")
assert result["status"] == 404
assert "names are display labels" in result["error"]
assert result["did_you_mean"][0]["ws_id"] == real
assert captured == []
# ---------------------------------------------------------------------------
# Read ops — storage-backed
# ---------------------------------------------------------------------------
@@ -558,34 +612,42 @@ def test_inspect_missing_ws_returns_error(populated_storage):
assert "error" in result
def test_inspect_not_found_does_not_echo_ws_id_in_error_string(populated_storage):
"""The error STRING is bare ("workstream not found") — the
structured ``ws_id`` field carries the queried id. Pre-fix the
error message echoed the ws_id back at the caller who just sent
it, which was redundant and a stylistic departure from the rest
of the surface. Echo-in-string is also one more place a
hostile/oversize ws_id could land in operator-facing text."""
def test_inspect_not_found_references_ref_but_clips_oversize(populated_storage):
"""The error string names the unresolvable ref — it sits next to
the did-you-mean hints now, so it's load-bearing context — but
clips it to a bounded length so a hostile / oversize ws_id can't
flood operator-facing text (the prior bare-string design's
concern). The structured ``ws_id`` field carries the full
value, and the format note reports the true length."""
client = _make_read_client(populated_storage)
result = client.inspect("does-not-exist-xyz")
assert result["error"] == "workstream not found"
# The structured field still carries the ws_id for context.
assert "does-not-exist-xyz" in result["error"]
assert result["ws_id"] == "does-not-exist-xyz"
oversize = "z" * 300
clipped = client.inspect(oversize)
assert oversize not in clipped["error"]
assert "(got 300)" in clipped["error"]
assert clipped["ws_id"] == oversize
def test_inspect_cross_tenant_returns_same_shape_as_missing(populated_storage):
"""The cross-tenant guard MUST return the exact same shape as a
genuinely missing ws_id that's the existence-leak defence the
error-string echo was carrying weight for too. Asserting the
shape match here pins the property going forward."""
# ``unrelated`` exists in storage but is not a coord-1 child.
genuinely missing ws_id the existence-leak defence. The error
text embeds the (caller-supplied) ref, so compare with the refs
factored out; same-length refs make the strings otherwise
byte-identical."""
# ``unrelated`` exists in storage but is not a coord-1 child;
# ``missing-x`` (same length) doesn't exist at all.
client = _make_read_client(populated_storage)
cross_tenant = client.inspect("unrelated")
missing = client.inspect("does-not-exist-abc")
# Same key set, same error string, only the ws_id field differs.
missing = client.inspect("missing-x")
assert cross_tenant.keys() == missing.keys()
assert cross_tenant["error"] == missing["error"] == "workstream not found"
assert "no workstream matching" in missing["error"]
assert cross_tenant["error"].replace("unrelated", "X") == missing["error"].replace(
"missing-x", "X"
)
assert cross_tenant["ws_id"] == "unrelated"
assert missing["ws_id"] == "does-not-exist-abc"
assert missing["ws_id"] == "missing-x"
def test_list_children_excludes_closed_by_default(tmp_path):
@@ -1252,76 +1314,266 @@ def test_wait_for_workstream_all_mode_times_out_on_running_child(populated_stora
assert result["results"]["child-b"]["state"] == "running"
def test_wait_for_workstream_denies_foreign_ws_id(populated_storage):
"""A ws_id outside the coordinator's subtree returns state='denied'.
With mode='any' on a pure-denied list there's no real work to wait
for, so the wait short-circuits sub-second with complete=False
the model sees the denied state immediately and can correct rather
than spinning the timeout."""
def test_wait_for_workstream_foreign_legacy_ref_fails_validation(populated_storage):
"""A ref outside the coordinator's subtree that isn't id-shaped
('unrelated') dies at the validation boundary: the call errors
immediately with a per-ref recovery payload and performs no
waiting at all."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["unrelated"], timeout=5, mode="any")
assert result["results"]["unrelated"]["state"] == "denied"
assert result["complete"] is False
assert result["elapsed"] < 1.0
assert result["elapsed"] == 0.0
assert result["results"] == {}
assert "no workstream matching" in result["error"]
assert result["invalid_ws_ids"][0]["ws_id"] == "unrelated"
# Unified channel shape: trimmed per-ref entries, roster once at
# top level (same as the in-loop not_found channel).
assert "children" not in result["invalid_ws_ids"][0]
assert {c["ws_id"] for c in result["children"]} == {"child-a", "child-b", "child-coord"}
def test_wait_for_workstream_denies_cross_tenant_child(populated_storage):
def test_wait_for_workstream_cross_tenant_child_fails_validation(populated_storage):
"""Defense-in-depth (Copilot #506): a row whose ``parent_ws_id``
matches the coordinator but whose ``user_id`` belongs to a
different tenant must collapse to ``denied`` otherwise a
forged / migration-era / pre-tenant-gate row would let a
coordinator's LLM observe foreign-tenant state through
different tenant must stay unobservable. The validation roster is
tenant-filtered in SQL, so the forged row never resolves and the
coordinator's LLM can't observe foreign-tenant state through
``wait_for_workstream``. The ``populated_storage`` fixture's
``cross-tenant-child`` row has exactly this shape
(parent_ws_id="coord-1", user_id="user-2").
"""
(parent_ws_id="coord-1", user_id="user-2")."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["cross-tenant-child"], timeout=5, mode="any")
assert result["results"]["cross-tenant-child"]["state"] == "denied"
assert result["complete"] is False
assert result["elapsed"] < 1.0
assert result["results"] == {}
assert "no workstream matching" in result["error"]
def test_wait_for_workstream_missing_ws_id_indistinguishable_from_denied(populated_storage):
"""A ws_id that doesn't exist collapses into the same 'denied'
shape as a foreign ws_id so wait can't be used as an existence
oracle (matches the 404-mask contract inspect uses). Same
short-circuit semantics as the pure-foreign case."""
def test_wait_for_workstream_missing_ref_indistinguishable_from_foreign(populated_storage):
"""A ref that doesn't exist produces the same payload as a foreign
one (same-length refs make the error strings byte-identical once
the echoed ref is factored out), so the validation boundary can't
be used as an existence oracle."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["does-not-exist"], timeout=5, mode="any")
assert result["results"]["does-not-exist"]["state"] == "denied"
foreign = client.wait_for_workstream(["unrelated"], timeout=5)
missing = client.wait_for_workstream(["missing-x"], timeout=5)
f_err, m_err = foreign["invalid_ws_ids"][0], missing["invalid_ws_ids"][0]
assert f_err.keys() == m_err.keys()
assert f_err["error"].replace("unrelated", "X") == m_err["error"].replace("missing-x", "X")
def test_wait_for_workstream_mixed_invalid_ref_errors_whole_call(populated_storage):
"""Successor to the bug-2 false-positive regression: one valid
(running) child plus one unresolvable ref must never produce a
'complete' wait. Under the fail-fast contract the whole call
errors immediately a partial wait over the valid subset would
hide exactly the lost-lane failure the validation exists to
surface."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["child-b", "unrelated"], timeout=5, mode="any")
assert result["complete"] is False
assert result["elapsed"] < 1.0
assert result["elapsed"] == 0.0
assert result["results"] == {}
assert result["invalid_ws_ids"][0]["ws_id"] == "unrelated"
# mode='all' is identical — previously a denied member counted as
# 'settled' and the wait completed, silently dropping the lane.
result_all = client.wait_for_workstream(["child-a", "unrelated"], timeout=5, mode="all")
assert result_all["complete"] is False
assert result_all["results"] == {}
def test_wait_for_workstream_any_does_not_short_circuit_on_mixed_denied(populated_storage):
"""Regression for the bug-2 false-positive: mode='any' with one
real (running) child and one denied id must NOT return
complete=True on the denied id wait until the real child reaches
a real terminal state, or time out."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["child-b", "unrelated"], timeout=1.0, mode="any")
# child-b never reaches terminal in the test fixture; denied alone
# must not satisfy the any condition; wait must hit the timeout.
# ---------------------------------------------------------------------------
# wait_for_workstream — in-loop not_found fail-fast (32-hex refs)
# ---------------------------------------------------------------------------
#
# Production ws_ids are ``uuid4().hex``. A well-formed-but-unobservable
# id passes the validation boundary and must abort the wait on the first
# tick that sees it — never burn the timeout, never ride along to a
# "complete" result. The fixture mirrors the original field incident: a
# coordinator LLM collapsed the ``aaa`` run in a child's id to a single
# ``a`` and then read the resulting not-found as a dead child.
REAL_CHILD_HEX = "7c61eafe470c54caaa89490a4b9c0f7d"
CORRUPTED_CHILD_HEX = "7c61eafe470c54ca89490a4b9c0f7d" # aaa -> a, 30 chars
RUNNING_CHILD_HEX = "9cc8205058d528130fb469eaf75650f3"
FOREIGN_HEX = "f" * 32
MISSING_HEX = "e" * 32
FORGED_HEX = "d" * 32 # parent_ws_id forged to coord-1, foreign user_id
@pytest.fixture
def hex_storage(tmp_path):
st = SQLiteBackend(str(tmp_path / "coord-hex.db"))
st.register_workstream("coord-1", kind="coordinator", user_id="user-1")
st.register_workstream(
REAL_CHILD_HEX,
kind="interactive",
parent_ws_id="coord-1",
state="idle",
user_id="user-1",
name="minisforum-research",
)
st.register_workstream(
RUNNING_CHILD_HEX,
kind="interactive",
parent_ws_id="coord-1",
state="running",
user_id="user-1",
name="beelink-research",
)
st.register_workstream(FOREIGN_HEX, kind="interactive", user_id="user-2")
st.register_workstream(FORGED_HEX, kind="interactive", parent_ws_id="coord-1", user_id="user-2")
return st
def test_wait_incident_regression_corrupted_id_gets_did_you_mean(hex_storage):
"""THE incident: a 30-char id (character-run collapse) must fail the
call instantly with the real child id as a did-you-mean pre-fix
it burned the full timeout and read as a dead child."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([CORRUPTED_CHILD_HEX], timeout=300, mode="all")
assert result["complete"] is False
assert result["elapsed"] >= 1.0
assert result["results"]["unrelated"]["state"] == "denied"
assert result["results"]["child-b"]["state"] == "running"
assert result["elapsed"] == 0.0
assert result["results"] == {}
bad = result["invalid_ws_ids"][0]
assert bad["ws_id"] == CORRUPTED_CHILD_HEX
assert bad["did_you_mean"][0]["ws_id"] == REAL_CHILD_HEX
assert bad["did_you_mean"][0]["name"] == "minisforum-research"
assert "(got 30)" in bad["error"]
def test_wait_for_workstream_all_completes_when_real_terminal_and_denied_mixed(
populated_storage,
):
"""mode='all' should consider denied ids as 'settled' so a wait on
[real-idle, denied] completes after the first tick instead of
waiting out the timeout the model gets the full results dict
and can act on the per-id state."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["child-a", "unrelated"], timeout=5, mode="all")
def test_wait_foreign_hex_id_aborts_on_first_tick(hex_storage):
"""A well-formed foreign id passes validation, snapshots as
``not_found``, and aborts the wait immediately even in mode='any'
with a real running child alongside (the old contract silently
waited out the timeout here)."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([RUNNING_CHILD_HEX, FOREIGN_HEX], timeout=30, mode="any")
assert result["complete"] is False
assert result["elapsed"] < 5.0
assert result["results"][FOREIGN_HEX]["state"] == "not_found"
assert result["results"][FOREIGN_HEX]["message"] == (
"(no workstream with this id among your children)"
)
assert result["results"][RUNNING_CHILD_HEX]["state"] == "running"
assert [h["ws_id"] for h in result["not_found"]] == [FOREIGN_HEX]
assert "no workstream matching" in result["error"]
assert {c["ws_id"] for c in result["children"]} == {REAL_CHILD_HEX, RUNNING_CHILD_HEX}
def test_wait_mode_all_never_completes_with_not_found_member(hex_storage):
"""Successor to the silent-ride-along: mode='all' with [idle,
foreign] previously returned complete=True (denied counted as
'settled'), reporting success while a lane was missing. Now the
unobservable member aborts the call with complete=False."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([REAL_CHILD_HEX, FOREIGN_HEX], timeout=5, mode="all")
assert result["complete"] is False
assert result["results"][FOREIGN_HEX]["state"] == "not_found"
assert result["results"][REAL_CHILD_HEX]["state"] == "idle"
def test_wait_foreign_and_missing_hex_payloads_identical(hex_storage):
"""Existence-oracle pin for the fail-fast path: an existing
foreign-tenant id and a nonexistent id produce identical result
entries and identical top-level hints (modulo the echoed ref)."""
client = _make_read_client(hex_storage)
foreign = client.wait_for_workstream([FOREIGN_HEX], timeout=5)
missing = client.wait_for_workstream([MISSING_HEX], timeout=5)
assert foreign["results"][FOREIGN_HEX] == missing["results"][MISSING_HEX]
f_hint, m_hint = foreign["not_found"][0], missing["not_found"][0]
assert f_hint.keys() == m_hint.keys()
assert f_hint["error"].replace(FOREIGN_HEX, "ID") == m_hint["error"].replace(MISSING_HEX, "ID")
def test_wait_results_carry_child_display_name(hex_storage):
"""Own-child entries carry the display ``name`` for orientation —
a label, not an address."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([REAL_CHILD_HEX], timeout=5, mode="any")
assert result["complete"] is True
assert result["elapsed"] < 1.0
assert result["results"]["child-a"]["state"] == "idle"
assert result["results"]["unrelated"]["state"] == "denied"
assert result["results"][REAL_CHILD_HEX]["name"] == "minisforum-research"
def test_wait_mid_wait_hard_delete_aborts(hex_storage, monkeypatch):
"""A child hard-deleted while a wait is in flight flips to
``not_found`` on the next tick and aborts the wait the
coordinator hears about the vanished lane in seconds, not at
timeout."""
monkeypatch.setattr(CoordinatorClient, "_WAIT_HEARTBEAT_INTERVAL", 0.05)
client = _make_read_client(hex_storage)
def _delete_soon() -> None:
time.sleep(0.3)
hex_storage.delete_workstream(RUNNING_CHILD_HEX)
deleter = threading.Thread(target=_delete_soon)
deleter.start()
try:
result = client.wait_for_workstream([RUNNING_CHILD_HEX], timeout=30, mode="all")
finally:
deleter.join()
assert result["complete"] is False
assert result["results"][RUNNING_CHILD_HEX]["state"] == "not_found"
assert result["elapsed"] < 10.0
def test_inspect_corrupted_id_gets_did_you_mean(hex_storage):
"""inspect_workstream shares the validation boundary: the incident
id gets the did-you-mean pointer, and the real id still inspects."""
client = _make_read_client(hex_storage)
result = client.inspect(CORRUPTED_CHILD_HEX)
assert result["did_you_mean"][0]["ws_id"] == REAL_CHILD_HEX
assert "(got 30)" in result["error"]
ok = client.inspect(REAL_CHILD_HEX)
assert ok["state"] == "idle"
def test_inspect_rejects_forged_cross_tenant_hex_row(hex_storage):
"""Parity with the wait / mutating gates (#506): a row forged with
parent_ws_id=coord but a foreign user_id must not be readable
through inspect either same not-found shape, no history leak."""
client = _make_read_client(hex_storage)
result = client.inspect(FORGED_HEX)
assert "no workstream matching" in result["error"]
assert "messages" not in result
def test_ws_ref_validation_survives_roster_query_failure(hex_storage, monkeypatch):
"""Storage failure during the roster read degrades hints to empty
but validation still errors honestly (never resolves blind)."""
client = _make_read_client(hex_storage)
def _boom(*args: object, **kwargs: object) -> None:
raise RuntimeError("storage down")
monkeypatch.setattr(hex_storage, "list_workstreams", _boom)
result = client.send("not-a-real-id", "hi")
assert result["status"] == 404
assert "no workstream matching" in result["error"]
assert result["children"] == []
def test_uppercase_full_hex_ref_case_folds(hex_storage):
"""Models occasionally upcase hex; a full 32-hex ref resolves
case-insensitively."""
client = _make_read_client(hex_storage)
ok = client.inspect(REAL_CHILD_HEX.upper())
assert ok.get("error") is None
assert ok["state"] == "idle"
def test_wait_since_hint_does_not_mask_not_found(hex_storage):
"""The not_found fail-fast outranks the since-diff early exit — a
diffing since hint must not convert an unobservable-id abort into
complete=True."""
client = _make_read_client(hex_storage)
since = {RUNNING_CHILD_HEX: {"state": "idle", "tokens": 0, "updated": ""}}
result = client.wait_for_workstream(
[RUNNING_CHILD_HEX, FOREIGN_HEX], timeout=5, mode="any", since=since
)
assert result["complete"] is False
assert result["results"][FOREIGN_HEX]["state"] == "not_found"
def test_wait_for_workstream_rejects_invalid_mode(populated_storage):
@@ -1788,16 +2040,21 @@ def test_wait_for_workstream_closed_returns_sentinel(populated_storage):
assert snap["truncated"] is False
def test_wait_for_workstream_denied_returns_sentinel(populated_storage):
"""Cross-tenant / nonexistent ws_ids surface as denied — the
sentinel lets the coord LLM recognise the rejection without
parsing state strings on its own."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["unrelated"], timeout=5, mode="any")
snap = result["results"]["unrelated"]
assert snap["state"] == "denied"
assert snap["message"].startswith("(workstream denied")
def test_wait_for_workstream_not_found_returns_sentinel(hex_storage):
"""Unobservable ws_ids surface a fixed sentinel message so the
coord LLM recognises the rejection without parsing state strings
on its own."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([FOREIGN_HEX], timeout=5, mode="any")
snap = result["results"][FOREIGN_HEX]
assert snap["state"] == "not_found"
assert snap["message"] == "(no workstream with this id among your children)"
assert snap["truncated"] is False
# One key set across real and not_found entries — uniform consumer
# access, no per-state conditionals (updated/name empty here).
assert set(snap) == {"state", "tokens", "updated", "name", "message", "truncated"}
assert snap["updated"] == ""
assert snap["name"] == ""
def test_wait_for_workstream_running_child_message_is_null(populated_storage):
+45
View File
@@ -8,6 +8,8 @@ visitor lands on the page but all API calls fail).
from __future__ import annotations
import re
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
@@ -401,6 +403,49 @@ def test_coord_dedups_system_turn_against_history_by_event_id():
"false-skip after clear_ui / replay_truncated."
)
# The seam must be wired on BOTH read paths, not merely present somewhere
# in the file — a refactor that keeps the Set but drops the live-handler
# consultation (or the history-side record) silently re-opens the
# double-render. Scope each assertion to its block so the wiring, not the
# bare symbol, is pinned. (A dedupe-neutered factory — guard short-circuited
# to ``false`` — still contains ``renderedSystemEventIds.has(`` and so
# passes the file-global checks above; these slice checks catch it.)
sys_case = body.index('case "system_turn":')
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
# ``...has(sysEid)) break;`` is itself a break that precedes the ``.add(``,
# so a ``break;``-bounded slice would drop the record half.
# Whitespace-tolerant so a reformat can't silently break the bound.
next_case = re.search(r'\n\s*case "', body[sys_case + 1 :])
assert next_case, (
"no switch case found after system_turn to bound the pin slice — if "
"system_turn became the last case, re-anchor this pin's end marker."
)
live_block = body[sys_case : sys_case + 1 + next_case.start()]
assert "renderedSystemEventIds.has(" in live_block, (
"the live system_turn handler must CONSULT the dedup set (skip an id "
"already painted from /history) — not just reference the Set elsewhere."
)
assert "renderedSystemEventIds.add(" in live_block, (
"the live system_turn handler must RECORD the id it renders so a later "
"/history re-render (clear_ui) doesn't repaint it."
)
# The history render path must seed the set from each replayed system row's
# event_id, so a subsequent live replay of the same id is skipped. Bound
# the slice structurally — from the system-role branch to the next role
# branch in the same chain (falling back to a generous window when it's
# the last branch) — so adding comments/fields inside the branch can't
# false-fail a pin that only cares about the wiring.
assert 'role === "system"' in body
sys_replay = body.index('role === "system"', body.index("refetchHistory"))
next_role = re.search(r"role\s*===", body[sys_replay + 1 :])
replay_end = sys_replay + 1 + next_role.start() if next_role else sys_replay + 1500
replay_window = body[sys_replay:replay_end]
assert "renderedSystemEventIds.add(" in replay_window, (
"the history render's system-role branch must record each replayed "
"turn's event_id so the live system_turn handler can dedup against it."
)
def test_coord_retry_walk_skips_operator_context_cards():
"""Retry must NOT regenerate a stale assistant turn when the last DOM row is
+36
View File
@@ -184,6 +184,42 @@ def test_approval_keyboard_shortcuts_wired() -> None:
)
def test_media_playback_lifted_and_pane_owned() -> None:
"""The media Play affordance is rendered by the pane (buildPlayButton /
buildMediaEmbed), so its activation must live in the pane too the old
standalone wired a DOCUMENT-level click/keydown listener in app.js, which
the console host never loaded (so the button was dead in console-hosted
panes). The fix mirrors the approval-keydown pattern: a pane-owned listener
on this.el, root-scoped via closest(".media-play-btn"). Pin both the
lifted helpers and the pane wiring so the document-level regression can't
silently come back."""
body = _INTERACTIVE.read_text(encoding="utf-8")
# The lifted activation machinery now lives in the shared module.
for fn in (
"function _loadHls(",
"function _isHlsUrl(",
"function _activatePlayer(",
"function activateMediaPlayButton(",
):
assert fn in body, f"media player helper must be lifted into the pane: {fn}"
# The HLS vendor is fetched by absolute /shared/ URL (resolves in BOTH the
# standalone server and the console, where /shared is mounted at the root).
assert 'script.src = "/shared/hls-1.6.16/hls.min.js";' in body
# Pane-owned + root-scoped — NOT a document-level delegated listener.
assert 'this.el.addEventListener("click"' in body, (
"media play must be wired on this.el (pane-owned), not document"
)
assert 'e.target.closest(".media-play-btn")' in body, (
"the play handler must be root-scoped via closest, not a document-wide id"
)
assert "activateMediaPlayButton(btn)" in body
collapsed = _strip_comments(body)
assert 'document.addEventListener("click"' not in collapsed, (
"the pane must not register a document-level click delegate — that is "
"the standalone regression that left console panes dead"
)
def test_controller_terminal_dead_state() -> None:
"""Lifecycle round 2: the console controller must STOP reconnect-polling a
session that is gone (closed / evicted / node restarted) three consecutive
+175
View File
@@ -2913,6 +2913,181 @@ class TestMemoryCompositionDeferral:
assert session._system_composed_with_context is False
class TestMemoryAccessTouch:
"""Access metadata (``access_count`` / ``last_accessed``) moves only when
the model actually sees a memory: the injected top-k during composition,
and explicit search/get reads via the memory tool. Save/list and the
wider candidate pool must NOT bump the counter.
"""
@staticmethod
def _access_count(name: str, scope: str = "global", scope_id: str = "") -> int:
from turnstone.core.storage import get_storage
mem = get_storage().get_structured_memory_by_name(name, scope, scope_id)
assert mem is not None, f"memory {name!r} not found"
return int(mem["access_count"])
@staticmethod
def _save(name: str, content: str) -> None:
from turnstone.core.memory import save_structured_memory
save_structured_memory(name, content, scope="global")
@staticmethod
def _empty_session() -> ChatSession:
"""A session whose __init__ composed before any memory existed.
The constructor composes the system prefix once; building it before
the memories are saved keeps that first (empty) compose from touching
rows, so the tests observe only the turn-driven recompose below.
"""
return _make_session(ws_id="ws-1", user_id="user-1")
@staticmethod
def _compose_turn(session: ChatSession, query: str) -> None:
"""Drive one user turn's worth of composition.
Mirrors ``send``: a fresh user turn invalidates the per-turn memory
caches, then the prefix recomposes against the new query.
"""
session._invalidate_memory_cache()
session.messages.append(turn_from_dict({"role": "user", "content": query}))
session._init_system_messages()
def test_composition_touches_injected_memories(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
self._save("kafka_alerts", "kafka consumer lag alert thresholds")
self._compose_turn(session, "how do I restart kafka")
# Both query-matching memories were injected, so both got touched once.
assert self._access_count("kafka_runbook") == 1
assert self._access_count("kafka_alerts") == 1
def test_composition_skips_unmatched_candidates(self, tmp_db):
"""The candidate pool is a superset of the injected set — a memory
that loses BM25 ranking (no query overlap) must NOT be touched."""
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
self._save("garden_notes", "tomato watering schedule midsummer")
self._compose_turn(session, "restart kafka broker pods status")
# The matching memory was injected and touched.
assert self._access_count("kafka_runbook") == 1
# The non-matching one was a candidate but never injected.
assert self._access_count("garden_notes") == 0
# Sanity: it really was in the visible candidate pool.
visible = {m["name"] for m in session._list_visible_memories()}
assert "garden_notes" in visible
def test_composition_touches_each_memory_once_per_turn(self, tmp_db):
"""``_init_system_messages`` runs many times within a turn (tool
results, MCP refresh); the injected set must be touched at most once
per memory between user turns, not once per recompose."""
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
self._compose_turn(session, "how do I restart kafka")
# Several mid-turn recomposes (no new user turn between them).
session._init_system_messages()
session._init_system_messages()
assert self._access_count("kafka_runbook") == 1
# A genuinely new turn lets the same memory be counted again.
self._compose_turn(session, "kafka again please")
assert self._access_count("kafka_runbook") == 2
def test_composition_touches_exactly_the_injected_keys(self, tmp_db):
"""Spy the touch boundary and assert the keys match the names the
composer rendered into the ``<memories>`` block exactly, not the
candidate pool."""
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
self._save("garden_notes", "tomato watering schedule midsummer")
session._invalidate_memory_cache()
session.messages.append(
turn_from_dict({"role": "user", "content": "restart kafka broker pods status"})
)
touched: list[tuple[str, str, str]] = []
with patch(
"turnstone.core.session.touch_structured_memories",
side_effect=lambda keys: touched.extend(keys),
):
session._init_system_messages()
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
touched_names = {name for name, _, _ in touched}
assert touched_names == {"kafka_runbook"}
assert '<memory name="kafka_runbook"' in joined
assert '<memory name="garden_notes"' not in joined
def test_composition_survives_touch_storage_error(self, tmp_db):
"""A storage blow-up inside the touch must not break composition —
the facade swallows it and the memory block still lands."""
from turnstone.core.storage import get_storage
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
session._invalidate_memory_cache()
session.messages.append(
turn_from_dict({"role": "user", "content": "how do I restart kafka"})
)
with patch.object(
get_storage(),
"touch_structured_memories",
side_effect=RuntimeError("storage exploded"),
):
session._init_system_messages()
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
assert '<memory name="kafka_runbook"' in joined
def test_search_action_touches_returned_hits(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"})
assert "error" not in item
session._exec_memory(item)
assert self._access_count("kafka_runbook") == 1
def test_get_action_touches_fetched_memory(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory(
"call_1", {"action": "get", "name": "kafka_runbook", "scope": "global"}
)
assert "error" not in item
session._exec_memory(item)
assert self._access_count("kafka_runbook") == 1
def test_get_miss_touches_nothing(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory(
"call_1", {"action": "get", "name": "no_such_mem", "scope": "global"}
)
_, msg = session._exec_memory(item)
assert "not found" in msg
# The existing row must not be collaterally touched by a miss.
assert self._access_count("kafka_runbook") == 0
def test_list_action_does_not_touch(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory("call_1", {"action": "list"})
session._exec_memory(item)
assert self._access_count("kafka_runbook") == 0
def test_save_action_does_not_touch_access_count(self, tmp_db):
"""The save action handler itself must not bump ``access_count`` —
that counter is read traffic only. (The recompose a save triggers
may surface the row via the composition path; that is exercised by
the composition tests. Suppressed here to isolate the handler.)"""
session = self._empty_session()
item = session._prepare_memory(
"call_1",
{"action": "save", "name": "kafka_runbook", "content": "x", "scope": "global"},
)
with patch.object(session, "_init_system_messages"):
session._exec_memory(item)
assert self._access_count("kafka_runbook") == 0
class TestMetacognitiveBuffers:
"""Nudges drain through advisory channels, not the system message."""
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.6.0rc2"
__version__ = "1.6.1"
+452 -95
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
import concurrent.futures
import json
import re
import secrets
import threading
import time
@@ -52,16 +53,15 @@ from turnstone.core.workstream import WorkstreamKind
WAIT_REAL_TERMINAL_STATES: frozenset[str] = frozenset({"idle", "error", "closed", "deleted"})
# Reportable terminal states — superset of the real ones, also includes the
# ``denied`` short-circuit shape returned for foreign / missing ws_ids.
# Used inside ``wait_for_workstream`` to decide when ``mode='any'`` on a
# pure-denied list should short-circuit with ``complete=False`` (no real
# work to wait for) and when ``mode='all'`` has fully settled. NOT used
# for the ``mode='any'`` real-terminal completion condition and NOT used
# by the resolved-count summary (which counts only real terminals —
# ``denied`` is a rejection, not a resolution). A single typo'd /
# foreign id shouldn't satisfy ``mode="any"`` and let the model declare
# a wait complete while every real child is still running.
WAIT_TERMINAL_STATES: frozenset[str] = WAIT_REAL_TERMINAL_STATES | frozenset({"denied"})
# ``not_found`` shape returned for foreign / missing / mid-wait-deleted
# ws_ids. ``not_found`` is NOT a completion state: the wait loop fails
# fast the moment any polled id reports it (an unobservable member makes
# the requested wait unsatisfiable — see the fail-fast block in
# ``wait_for_workstream``), and the resolved-count summary counts only
# real terminals. This set's remaining job is the message-sentinel
# branch in the result enrichment: states whose ``message`` is a fixed
# sentinel rather than a storage read.
WAIT_TERMINAL_STATES: frozenset[str] = WAIT_REAL_TERMINAL_STATES | frozenset({"not_found"})
# Hard cap on ws_ids per call. Polling happens once per ws_id per tick, so a
# runaway list would amplify storage load without giving the model anything
@@ -121,9 +121,64 @@ _WAIT_MESSAGE_TAIL_LIMIT: int = 20
# followed by an error) would otherwise produce a sentinel that
# falsely claims no output exists at all.
_WAIT_SENTINEL_CLOSED = "(workstream closed)"
_WAIT_SENTINEL_DENIED = "(workstream denied: not in coordinator subtree or does not exist)"
_WAIT_SENTINEL_NOT_FOUND = "(no workstream with this id among your children)"
_WAIT_SENTINEL_NO_RECENT_ASSISTANT = "(no recent assistant output)"
# ---------------------------------------------------------------------------
# Model-supplied workstream-id validation — the coordinator LLM hand-copies
# ws_ids between tool results and tool calls, and models garble long hex
# runs (the canonical incident: a 32-char id whose ``aaa`` run collapsed to
# a single ``a``, leaving a 30-char id no tool could act on, which then
# read back to the model as a dead child). ws_id arguments are therefore
# validated at the tool boundary:
#
# - a full 32-hex id passes straight through (ownership still enforced by
# the per-verb guards, at unchanged storage cost);
# - a direct child's exact id of any other shape still resolves (legacy /
# synthetic ids predate the 32-hex convention);
# - anything else — truncated, garbled, non-hex, or a display name —
# fails fast with a did-you-mean + a roster of the coordinator's own
# children, so a garbled id is recoverable in one round-trip.
#
# Near-miss ids are NEVER auto-resolved — a mutating verb must not guess.
# Display names are NOT addresses (they're mutable and non-unique); a ref
# matching a child's name errors with a pointer at the right ws_id.
# Validation and every hint consult ONLY the coordinator's own direct
# children, preserving the no-existence-oracle guarantee for foreign ids.
# ---------------------------------------------------------------------------
# A well-formed ws_id: exactly 32 lowercase hex chars (``uuid4().hex``).
_WS_REF_ID_RE = re.compile(r"^[0-9a-f]{32}$")
# Max Levenshtein distance for a did-you-mean candidate. The incident
# class (character-run collapse / duplication / single-char typo) sits at
# distance 1-2; unrelated 32-hex ids sit at ~28+, so 3 is generous
# headroom with no false-positive risk in practice.
_WS_REF_SUGGEST_DISTANCE: int = 3
# Children listed inline in an unresolvable-ws_id error. Enough to
# re-orient the model without flooding the tool result on wide fan-outs;
# the error text points at list_workstreams for the rest.
_WS_REF_ROSTER_CAP: int = 8
# Page size for the validation roster query — far above any practical
# direct-children count, so the exact-match / did-you-mean scans never
# judge against a silently truncated page.
_WS_REF_ROSTER_QUERY_LIMIT: int = 1000
# Did-you-mean candidates surfaced per unresolvable ref.
_WS_REF_SUGGEST_CAP: int = 2
# Echoed-ref clip applied inside error STRINGS — the structured
# ``ws_id`` field carries the full value and the format note reports
# the true length, so the clip only bounds operator-facing text.
# Covers a full 32-hex id with slack.
_WS_REF_ECHO_CLIP: int = 48
# Cap on the assembled top-level ``error`` string when several refs
# fail in one wait call.
_WS_REF_ERROR_TEXT_CAP: int = 2000
_TASK_STATUSES = frozenset({"pending", "in_progress", "done", "blocked"})
# Hard cap on tasks per coordinator — the full list is read and re-serialized
# on every mutation, so unbounded growth is both a storage and a tool-output-size
@@ -142,6 +197,45 @@ _TASK_TITLE_MAX = 200
_LIVE_CACHE_TTL_SECONDS = 2.0
def _levenshtein_capped(a: str, b: str, cap: int) -> int:
"""Levenshtein distance with an early-exit band.
Returns ``cap + 1`` as soon as the distance provably exceeds ``cap``,
so did-you-mean scans across a coordinator's children stay cheap per
candidate instead of O(len^2). Plain DP otherwise inputs are short
(ws_ids are 32 chars) so nothing cleverer is warranted.
"""
if a == b:
return 0
la, lb = len(a), len(b)
if abs(la - lb) > cap:
return cap + 1
if la > lb:
a, b, la, lb = b, a, lb, la
prev = list(range(la + 1))
for j in range(1, lb + 1):
cur = [j] + [0] * la
bj = b[j - 1]
row_best = cur[0]
for i in range(1, la + 1):
cost = 0 if a[i - 1] == bj else 1
cur[i] = min(prev[i] + 1, cur[i - 1] + 1, prev[i - 1] + cost)
row_best = min(row_best, cur[i])
if row_best > cap:
return cap + 1
prev = cur
return prev[la] if prev[la] <= cap else cap + 1
def _trim_ws_ref_hint(payload: dict[str, Any]) -> dict[str, Any]:
"""Per-ref entry for wait's ``invalid_ws_ids`` / ``not_found``
channels one shared shape: ``{ws_id, error, did_you_mean?}``.
The children roster is identical across refs in one call, so it
rides ONCE at the response top level instead of per entry.
"""
return {key: payload[key] for key in ("ws_id", "error", "did_you_mean") if key in payload}
def _utc_now_iso() -> str:
"""ISO-8601 UTC timestamp with seconds precision.
@@ -486,6 +580,177 @@ class CoordinatorClient:
return False
return bool(row.get("user_id")) and row.get("user_id") == self._user_id
# -- model-supplied ws_id validation ------------------------------------
def _children_roster(self) -> list[dict[str, Any]]:
"""Direct children of this coordinator (any kind, own tenant only).
Powers ws_id validation and the did-you-mean / roster blocks in
unresolvable-id errors. Unlike :meth:`list_children` this does
NOT filter ``kind`` a coordinator-kind child passes the
per-verb ownership guards (``_is_own_subtree`` checks parent +
user only), so validation must see the same set or a legacy
exact id could validate for one verb and 404 on another. One
SQL page capped at ``_WS_REF_ROSTER_QUERY_LIMIT``; failures
collapse to an empty roster (hints degrade, validation still
errors honestly).
"""
try:
raw = self._storage.list_workstreams(
limit=_WS_REF_ROSTER_QUERY_LIMIT,
parent_ws_id=self._coord_ws_id,
user_id=self._user_id or None,
)
except Exception:
log.debug("coord_client.ws_ref.roster_failed", exc_info=True)
return []
roster: list[dict[str, Any]] = []
for row in raw:
try:
m = row._mapping # SQLAlchemy Row
except AttributeError:
# Fallback for non-Row tuples (test doubles, etc.) —
# column order mirrors list_children's fallback map.
m = {"ws_id": row[0], "name": row[2], "state": row[3]}
roster.append(
{
"ws_id": str(m["ws_id"] or ""),
"name": str(m["name"] or ""),
"state": str(m["state"] or ""),
}
)
return roster
def _ws_ref_error(
self,
ref: str,
*,
roster: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Uniform unresolvable-ws_id error payload.
One shape for malformed / foreign / nonexistent ids: the message
never distinguishes "exists but isn't yours" from "doesn't
exist" (no existence oracle), and every hint it carries
(did-you-mean, roster) is computed from the coordinator's OWN
children only. Suggestions are advisory text nothing here
auto-resolves, so a near-miss id can never route a mutating verb
to a guessed target.
"""
if roster is None:
roster = self._children_roster()
ref_l = (ref or "").strip().lower()
# Clip the echoed ref in the STRING — a hostile / oversize ws_id
# must not flood operator-facing text (see _WS_REF_ECHO_CLIP).
shown = ref if len(ref) <= _WS_REF_ECHO_CLIP else ref[: _WS_REF_ECHO_CLIP - 3] + "..."
parts = [f"no workstream matching {shown!r} among your children"]
did: list[dict[str, str]] = []
name_hits = [c for c in roster if c["name"] and c["name"].strip().lower() == ref_l]
if name_hits:
# The model pasted a display NAME. Point it straight at the
# id — names are mutable, non-unique labels, deliberately not
# addresses.
did = [
{"ws_id": c["ws_id"], "name": c["name"]} for c in name_hits[:_WS_REF_SUGGEST_CAP]
]
parts.append(
"that is a child NAME, not an id — names are display "
f"labels; did you mean ws_id {did[0]['ws_id']}?"
)
else:
scored = sorted(
(
(_levenshtein_capped(ref_l, c["ws_id"], _WS_REF_SUGGEST_DISTANCE), c)
for c in roster
),
key=lambda pair: pair[0],
)
did = [
{"ws_id": c["ws_id"], "name": c["name"]}
for dist, c in scored
if dist <= _WS_REF_SUGGEST_DISTANCE
][:_WS_REF_SUGGEST_CAP]
if did:
parts.append(
"did you mean "
+ " or ".join(f"{c['ws_id']} ({c['name'] or 'unnamed'})" for c in did)
+ "?"
)
if not _WS_REF_ID_RE.fullmatch(ref_l):
parts.append(
f"ws ids are exactly 32 lowercase hex chars (got {len(ref_l)}) — "
"copy them verbatim from spawn_batch / list_workstreams results"
)
parts.append("use list_workstreams to re-check ids")
payload: dict[str, Any] = {
"error": "; ".join(parts),
"status": 404,
"ws_id": ref,
}
if did:
payload["did_you_mean"] = did
payload["children"] = [
{"ws_id": c["ws_id"], "name": c["name"], "state": c["state"]}
for c in roster[:_WS_REF_ROSTER_CAP]
]
payload["children_truncated"] = len(roster) > _WS_REF_ROSTER_CAP
return payload
def _resolve_ws_ref(
self,
ref: str,
*,
roster: list[dict[str, Any]] | None = None,
) -> tuple[str, dict[str, Any] | None]:
"""Validate a model-supplied ws_id argument.
Returns ``(ws_id, None)`` on success, ``("", error_payload)``
otherwise. Accepted shapes, in match order:
1. the coordinator's own ws_id, verbatim;
2. a full 32-lowercase-hex id (case-folded) passes through
WITHOUT a roster read, so the hot path costs exactly what it
did before validation existed; ownership stays with the
per-verb guards;
3. a direct child's EXACT id of any other shape — covers
legacy / synthetic ids that predate the 32-hex convention.
Anything else truncated, garbled, non-hex, a display name
fails with the did-you-mean payload. The pasted-a-name case is
called out explicitly in the error; near-miss ids are NEVER
auto-resolved.
"""
r = (ref or "").strip()
if not r:
return "", self._ws_ref_error(r, roster=roster)
if r == self._coord_ws_id:
return r, None
rl = r.lower()
if _WS_REF_ID_RE.fullmatch(rl):
return rl, None
if roster is None:
roster = self._children_roster()
for c in roster:
if c["ws_id"] in (r, rl):
return c["ws_id"], None
return "", self._ws_ref_error(r, roster=roster)
def _resolve_owned(self, ws_id: str) -> tuple[str, dict[str, Any] | None]:
"""Resolve + ownership-guard a model-supplied ws_id in one step.
Shared preamble for the mutating verbs (send / close / cancel /
delete): format-resolve via :meth:`_resolve_ws_ref`, then the
tenant gate via :meth:`_is_own_subtree`. Returns
``(ws_id, None)`` or ``("", error_payload)`` one home so a
future guard change (audit hook, logging) lands once.
"""
resolved, ref_err = self._resolve_ws_ref(ws_id)
if ref_err is not None:
return "", ref_err
if not self._is_own_subtree(resolved):
return "", self._ws_ref_error(resolved)
return resolved, None
# -- model-invoked mutating ops (HTTP) ---------------------------------
def spawn(
@@ -517,9 +782,10 @@ class CoordinatorClient:
return self._post("spawn", body)
def send(self, ws_id: str, message: str) -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
return self._post("send", {"message": message}, ws_id=ws_id)
resolved, ref_err = self._resolve_owned(ws_id)
if ref_err is not None:
return ref_err
return self._post("send", {"message": message}, ws_id=resolved)
def emit_audit(self, action: str, detail: dict[str, Any]) -> None:
"""Record an audit row attributed to this coordinator session.
@@ -541,12 +807,13 @@ class CoordinatorClient:
)
def close_workstream(self, ws_id: str, reason: str = "") -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
resolved, ref_err = self._resolve_owned(ws_id)
if ref_err is not None:
return ref_err
body: dict[str, Any] = {}
if reason:
body["reason"] = reason
return self._post("close", body, ws_id=ws_id)
return self._post("close", body, ws_id=resolved)
def close_all_children(self, reason: str = "") -> dict[str, Any]:
"""Soft-close every direct child of this coordinator (console-side fan-out).
@@ -563,9 +830,10 @@ class CoordinatorClient:
return self._post("close_all_children", body, ws_id=self._coord_ws_id)
def delete(self, ws_id: str) -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
return self._post("delete", {"ws_id": ws_id})
resolved, ref_err = self._resolve_owned(ws_id)
if ref_err is not None:
return ref_err
return self._post("delete", {"ws_id": resolved})
# -- console-endpoint helpers (NOT model-invoked tools) -----------------
@@ -587,9 +855,10 @@ class CoordinatorClient:
return self._post("approve", body, ws_id=ws_id)
def cancel(self, ws_id: str) -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
return self._post("cancel", {}, ws_id=ws_id)
resolved, ref_err = self._resolve_owned(ws_id)
if ref_err is not None:
return ref_err
return self._post("cancel", {}, ws_id=resolved)
def rewind(self, ws_id: str, turns: int) -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
@@ -630,16 +899,18 @@ class CoordinatorClient:
because hard-delete cascades the row out of storage, but it
stays in the set so a legacy / synthetic-test row carrying
that state still counts). ``mode='all'`` returns once every
ws_id has settled (real terminal OR ``denied``). Returns
``{"results": {ws_id: {state, tokens, updated, message, truncated}},
"elapsed": float, "complete": bool, "mode": mode}``. ``complete``
is True when the wait condition was met before the deadline,
False when the timeout fired (results carry whatever last state
was observed).
ws_id is real-terminal an id that can't be observed never
rides along to a "complete" result (see the not_found fail-fast
below). Returns
``{"results": {ws_id: {state, tokens, updated, name, message,
truncated}}, "elapsed": float, "complete": bool, "mode": mode}``.
``complete`` is True when the wait condition was met before the
deadline, False when the timeout fired (results carry whatever
last state was observed).
``message`` carries the child's last assistant message text for
``idle`` / ``error`` states, or a short status sentinel for
``closed`` / ``denied``. Non-terminal entries (e.g. ``running``
``closed`` / ``not_found``. Non-terminal entries (e.g. ``running``
after a timeout) and ``deleted`` rows carry ``None`` hard
deletes cascade rows out of storage so a real ``deleted`` state
is never observed; the legacy/synthetic-row path falls into the
@@ -665,13 +936,27 @@ class CoordinatorClient:
dict from silently exiting on tick one (which the naive
missing-entry-counts-as-changed rule would cause).
Cross-tenant guard: a ws_id that's neither the coordinator
itself nor one of its own children appears with
``state="denied"`` and never blocks the wait a model that
emits a foreign id learns immediately rather than spinning
until timeout. A ws_id that doesn't exist at all collapses
into the same ``denied`` shape so wait can't be used as an
existence oracle.
Unresolvable ids fail fast. Refs are validated up front a
malformed ws_id (truncated / garbled / non-hex / a display
name) errors immediately, before any waiting happens, with
top-level ``error`` / ``invalid_ws_ids`` / ``children`` fields.
Per-ref entries in ``invalid_ws_ids`` and ``not_found`` share
one shape ``{ws_id, error, did_you_mean}`` and the children
roster rides once at top level on both channels. A
well-formed id that is foreign, nonexistent, or hard-deleted
mid-wait surfaces as ``state="not_found"`` and aborts the wait
on the tick that observes it: ``complete=False`` plus top-level
``error`` / ``not_found`` / ``children`` fields. Without this,
an unobservable member either burns the whole timeout
(``mode='all'`` could never satisfy) or silently rides along to
a "complete" result missing a lane the original incident
shape. Foreign and nonexistent ids collapse into one
indistinguishable payload (no existence oracle); every hint
references only this coordinator's own children. Results are
keyed by the validated ws_id and share one key set
``not_found`` entries carry empty ``updated`` / ``name`` with
the display ``name`` filled in for own children as orientation
(names are labels, NOT addresses).
``progress_callback`` is invoked once per poll cycle with the
current snapshot dict + elapsed seconds. Swallows callback
@@ -731,6 +1016,45 @@ class CoordinatorClient:
"elapsed": 0.0,
"mode": mode,
}
# Validate model-supplied refs before anything else touches them.
# The roster query is skipped when every ref is already the coord
# itself or a full 32-hex id (the overwhelmingly common case), so
# validation adds no storage cost to the hot path.
needs_roster = any(
w != self._coord_ws_id and not _WS_REF_ID_RE.fullmatch(w.lower()) for w in cleaned
)
roster = self._children_roster() if needs_roster else None
resolved_ids: list[str] = []
resolved_seen: set[str] = set()
invalid: list[dict[str, Any]] = []
for ref in cleaned:
rid, ref_err = self._resolve_ws_ref(ref, roster=roster)
if ref_err is not None:
invalid.append(ref_err)
continue
if rid not in resolved_seen:
resolved_seen.add(rid)
resolved_ids.append(rid)
if invalid:
# Tool-boundary fail-fast: error the whole call rather than
# waiting on the valid subset — the model asked to observe a
# set it can't observe, and a partial wait hides exactly the
# lost-lane failure this guards against. Entries share the
# ``not_found`` channel's per-ref shape; the roster rides
# once at top level.
return {
"error": " | ".join(str(e.get("error") or "") for e in invalid)[
:_WS_REF_ERROR_TEXT_CAP
],
"invalid_ws_ids": [_trim_ws_ref_hint(e) for e in invalid],
"children": invalid[0].get("children", []),
"children_truncated": invalid[0].get("children_truncated", False),
"results": {},
"complete": False,
"elapsed": 0.0,
"mode": mode,
}
cleaned = resolved_ids
try:
timeout_f = float(timeout)
except (TypeError, ValueError):
@@ -755,7 +1079,8 @@ class CoordinatorClient:
aggregate batch) instead of two-per-id, cutting per-tick
round-trips from O(N) to O(1) at the documented cap.
Cross-tenant + missing-row cases collapse into a single
``denied`` shape so wait can't be used as an existence oracle.
``not_found`` shape so wait can't be used as an existence
oracle.
"""
try:
rows = self._storage.get_workstreams_batch(cleaned)
@@ -771,29 +1096,30 @@ class CoordinatorClient:
for wid in cleaned:
row = rows.get(wid)
if row is None or not self._row_in_own_subtree(wid, row):
snaps[wid] = {"state": "denied", "tokens": 0}
# Same key set as real entries (updated/name empty)
# so callers consume results[ws_id] uniformly.
snaps[wid] = {
"state": "not_found",
"tokens": 0,
"updated": "",
"name": "",
}
continue
snaps[wid] = {
"state": str(row.get("state") or ""),
"tokens": int(tokens_by_wid.get(wid, 0) or 0),
"updated": row.get("updated") or "",
"name": str(row.get("name") or ""),
}
return snaps
def _is_real_terminal(snap: dict[str, Any]) -> bool:
# Real-terminal — these states drive ``complete=True``.
# ``denied`` is intentionally excluded so a single typo'd /
# foreign / nonexistent ws_id can't satisfy ``mode="any"``
# while every real child is still running.
# ``not_found`` is intentionally excluded: an unobservable
# member can't satisfy ``mode="any"`` — it aborts the wait
# via the fail-fast below instead.
return snap.get("state", "") in self._WAIT_REAL_TERMINAL_STATES
def _is_settled(snap: dict[str, Any]) -> bool:
# Settled — terminal OR denied. Used to decide when the
# wait should give up because there's nothing left to
# observe (no real ws_ids in the polled set, or every real
# one has already finished).
return snap.get("state", "") in self._WAIT_TERMINAL_STATES
def _diff_since(snap: dict[str, Any], prev: dict[str, Any]) -> bool:
"""True when ``snap`` differs from the ``since`` hint on any
of the diffed fields. Called only for ws_ids that appear in
@@ -804,6 +1130,7 @@ class CoordinatorClient:
last_results: dict[str, dict[str, Any]] = {}
complete = False
not_found_ids: list[str] = []
# Subscribe to in-process state-change events for the watched
# ws_ids when the bus is wired. ``register_waiter`` returns a
# single ``threading.Event`` registered against every id so a
@@ -816,13 +1143,13 @@ class CoordinatorClient:
# registry on this console process, so a foreign ws_id passed by
# an untrusted coord LLM (prompt injection) would otherwise leak
# wake-up timing as a side channel — _snapshot_all returns
# ``denied`` for the content, but the *time* at which the wait
# ``not_found`` for the content, but the *time* at which the wait
# un-blocked would correlate with the foreign ws_id's next
# state-class event. Filter ``cleaned`` to own-subtree ids
# before registering; foreign / missing ws_ids stay in the
# snapshot list so they still surface as ``denied`` in
# ``_snapshot_all`` and exit via the pure-denied short-circuit
# below. Predicate shared with ``_snapshot_all`` via
# snapshot list so they still surface as ``not_found`` in
# ``_snapshot_all`` and exit via the not_found fail-fast in the
# loop. Predicate shared with ``_snapshot_all`` via
# :meth:`_row_in_own_subtree`.
try:
pre_rows = self._storage.get_workstreams_batch(cleaned)
@@ -849,7 +1176,20 @@ class CoordinatorClient:
except Exception:
log.debug("coord_client.wait.progress_cb_failed", exc_info=True)
real_terminal = [_is_real_terminal(snap) for snap in results.values()]
settled = [_is_settled(snap) for snap in results.values()]
# Fail fast on unobservable members — foreign, nonexistent,
# or hard-deleted mid-wait. Checked BEFORE the since-diff
# and mode conditions: an unobservable member invalidates
# the requested wait regardless of what the rest are doing
# (``mode='all'`` could never satisfy; ``mode='any'`` /
# ``since`` could "succeed" while silently dropping a
# lane). The snapshot already collapsed foreign and
# missing into one ``not_found`` shape, so exiting here
# leaks nothing a single-tick wait wouldn't.
not_found_ids = [
wid for wid, snap in results.items() if snap.get("state") == "not_found"
]
if not_found_ids:
break
# ``since`` — orthogonal to mode. If the caller supplied a
# prior snapshot, any diff on a ws_id that IS in ``since_map``
# exits the wait so a follow-up call doesn't re-count
@@ -869,19 +1209,13 @@ class CoordinatorClient:
if any(real_terminal):
complete = True
break
# Pure-denied list: every snap is settled but none is a
# real terminal — no work to wait for. Short-circuit so
# the model sees the denied results immediately rather
# than spinning the timeout (``complete=False`` because
# the wait condition never had a real chance to fire).
if all(settled):
break
else: # mode == "all"
if all(settled):
# Every ws_id is settled (real-terminal or denied).
# The wait condition is met — the model gets the
# full results dict and decides what each terminal
# state means.
if all(real_terminal):
# Every ws_id actually finished observable work.
# ``not_found`` members can't ride along to a
# "complete" result — the fail-fast above exits
# first — so ``complete=True`` means every lane
# really resolved.
complete = True
break
remaining = deadline - time.monotonic()
@@ -898,14 +1232,13 @@ class CoordinatorClient:
# a deliberate 4x trade vs the pre-bus 0.5 s poll.
wake_event.wait(min(remaining, self._WAIT_HEARTBEAT_INTERVAL))
else:
# Pure-foreign / pure-denied list: every cleaned
# ws_id was filtered out of ``own_subtree`` so the
# bus has nothing to wake on. The pure-denied
# short-circuit above exits ``mode='any'`` on the
# first tick; ``mode='all'`` falls through to here
# and must burn the timeout. Use the heartbeat
# cadence for the deadline carve-up so
# ``progress_callback`` keeps firing.
# No wake source for this wait (no registered own-
# subtree ids — e.g. a bus-less test fixture). Fall
# back to the heartbeat cadence so
# ``progress_callback`` keeps firing. Foreign /
# missing ids can't park here past one tick: the
# not_found fail-fast above exits on the tick that
# observes them.
time.sleep(min(self._WAIT_HEARTBEAT_INTERVAL, remaining))
finally:
# Always unregister so a crash mid-wait can't leak the
@@ -920,7 +1253,7 @@ class CoordinatorClient:
# Bundle each terminal child's last assistant message inline so the
# coordinator LLM doesn't have to follow up with one
# ``inspect_workstream`` per ws. Only ``idle`` / ``error`` ws_ids
# actually hit storage (``closed`` / ``denied`` return a sentinel
# actually hit storage (``closed`` / ``not_found`` return a sentinel
# without I/O), so split them and parallelize the storage-bound
# subset across a small thread pool — at the WAIT_MAX_WS_IDS=32
# cap, 8 workers cuts a worst-case all-idle fan-out from 32
@@ -965,12 +1298,30 @@ class CoordinatorClient:
else:
msg, trunc = None, False
enriched_results[wid] = {**snap, "message": msg, "truncated": trunc}
return {
response: dict[str, Any] = {
"results": enriched_results,
"complete": complete,
"elapsed": round(time.monotonic() - start, 3),
"mode": mode,
}
if not_found_ids:
# The wait aborted on unobservable members — surface a loud
# top-level error with recovery hints (did-you-mean + child
# roster) so a garbled id reads as "fix the id and re-issue",
# not as a dead child. Hints reference only this
# coordinator's own children; foreign and nonexistent ids
# produce identical payloads (no existence oracle).
hint_roster = self._children_roster()
hints = [self._ws_ref_error(wid, roster=hint_roster) for wid in not_found_ids]
response["not_found"] = [_trim_ws_ref_hint(h) for h in hints]
response["error"] = " | ".join(str(h.get("error") or "") for h in hints)[
:_WS_REF_ERROR_TEXT_CAP
]
response["children"] = hints[0].get("children", []) if hints else []
response["children_truncated"] = (
hints[0].get("children_truncated", False) if hints else False
)
return response
# -- model-invoked read ops (direct storage) ---------------------------
@@ -1495,10 +1846,11 @@ class CoordinatorClient:
Cross-tenant guard: the coordinator's LLM input is untrusted, so
the inspectable scope is restricted to (a) the coordinator
itself or (b) a row whose ``parent_ws_id`` is this coordinator
(i.e. one of its own children). Any other ws_id returns the
same not-found shape used for genuine misses, avoiding an
existence oracle.
itself or (b) one of its own children ``parent_ws_id`` AND
``user_id`` parity via :meth:`_row_in_own_subtree`, matching
the wait / mutating paths. Any other ws_id returns the same
not-found shape used for genuine misses, avoiding an existence
oracle.
``include_provider_content`` defaults to False. Provider-native
content blocks (``_provider_content`` / ``provider_blocks``)
@@ -1507,20 +1859,25 @@ class CoordinatorClient:
them for provider-fidelity replay tooling; regular inspect
calls get the trimmed shape.
"""
resolved, ref_err = self._resolve_ws_ref(ws_id)
if ref_err is not None:
return ref_err
ws_id = resolved
full = self._storage.get_workstream(ws_id)
# Echoing the ws_id back inside the error STRING was a stylistic
# carry-over — the structured ``ws_id`` field already carries
# the value the caller asked about. The bare error message
# ("workstream not found") is enough; the same shape is used
# for cross-tenant rows so the existence-leak guarantee is
# preserved either way.
miss = {"error": "workstream not found", "ws_id": ws_id}
# Misses return the same did-you-mean payload for nonexistent and
# cross-tenant rows alike, so the existence-leak guarantee is
# preserved while a garbled id stays recoverable in one
# round-trip (the structured ``ws_id`` field echoes the value
# the caller asked about).
if full is None:
return miss
is_self = ws_id == self._coord_ws_id
is_own_child = full.get("parent_ws_id") == self._coord_ws_id
if not (is_self or is_own_child):
return miss
return self._ws_ref_error(ws_id)
# Ownership parity with every other verb: parent AND user_id
# (``_row_in_own_subtree``). The parent-only check this
# replaces let a forged / migration-era row (parent_ws_id=coord,
# user_id=other-tenant) be read through inspect while the wait
# and mutating paths rejected the same shape (#506).
if not self._row_in_own_subtree(ws_id, full):
return self._ws_ref_error(ws_id)
# load_messages returns the full history in chronological order.
# We slice the tail in Python because the SQL tail-N is
# approximate across conversation boundaries. Defensive
@@ -2103,7 +2460,7 @@ def _wait_message_for(
exhaustion is more actionable than the prior assistant turn,
and the prior shape's "(no recent assistant output)" sentinel
hid that signal entirely.
- ``closed`` / ``denied`` short status sentinel. No
- ``closed`` / ``not_found`` short status sentinel. No
message-history read because there's nothing meaningful to
return a partial last message could be misleading mid-thought.
- any other state (e.g. ``running``, or a ``deleted`` synthetic /
@@ -2117,8 +2474,8 @@ def _wait_message_for(
already completed; the model just gets ``message: null`` for the
affected ws and can fall back to inspect).
"""
if state == "denied":
return _WAIT_SENTINEL_DENIED, False
if state == "not_found":
return _WAIT_SENTINEL_NOT_FOUND, False
if state == "closed":
return _WAIT_SENTINEL_CLOSED, False
if state == "error":
+10
View File
@@ -178,6 +178,7 @@ function showAdmin(tab) {
}
function switchAdminTab(tab) {
const tabChanged = tab !== _adminTab;
_adminTab = tab;
// Hide no-permissions empty state if it was showing
const noPerms = document.getElementById("admin-no-permissions");
@@ -207,6 +208,15 @@ function switchAdminTab(tab) {
if (el) el.style.display = panels[p] === tab ? "" : "none";
}
// One #admin-content scroller serves every panel — a leftover offset from
// a tall tab must not carry into the next (the app.js #main reset is the
// precedent for pane-local navigation). Same-tab re-entry (showAdmin on
// pane focus) keeps the user's place.
if (tabChanged) {
const contentEl = document.getElementById("admin-content");
if (contentEl) contentEl.scrollTop = 0;
}
if (tab === "users") loadAdminUsers();
if (tab === "tokens") _populateTokenUserSelect();
if (tab === "channels") _populateChannelUserSelect();
+4 -2
View File
@@ -119,21 +119,23 @@
<button
type="button"
id="persona-coordinator"
class="persona-btn active"
class="persona-btn persona-btn--coord active"
role="radio"
aria-checked="true"
tabindex="0"
>
<span class="persona-led" aria-hidden="true"></span>
Coordinator
</button>
<button
type="button"
id="persona-interactive"
class="persona-btn"
class="persona-btn persona-btn--int"
role="radio"
aria-checked="false"
tabindex="-1"
>
<span class="persona-led" aria-hidden="true"></span>
Interactive
</button>
</div>
+24 -4
View File
@@ -411,11 +411,20 @@
flex: 1;
}
/* Content area */
/* Content area the manage pane's interior scroller. The chain above it
(#view-admin .admin-layout) is height-pinned by the L-shell pane and the
.hatch-host clips, so this is the last box that can own tab overflow. It
scrolls so the docked shelf positioned against .admin-layout, OUTSIDE
this scroller stays put while tab content moves beneath it; the same
division of labor as #main inside the dashboard pane. */
.admin-content {
flex: 1;
min-width: 0;
overflow-y: auto;
padding-right: 20px;
/* scroll tail the last row must not hug the pane edge (#main keeps 60px;
the admin tables are denser, so less) */
padding-bottom: 24px;
}
.admin-toolbar {
@@ -746,9 +755,10 @@
text-overflow "…". The actions cell inherits `.admin-col`'s
`overflow: hidden`, which would re-clip a dropdown, so we override it
to `visible` and anchor an absolutely-positioned menu to the kebab
container. `.admin-content` does not scroll (the document does), so
the menu glued to its cell overlays the page without being
clipped by any ancestor. */
container. The menu lives inside the `.admin-content` scroller and
rides with its row; the viewport-aware flip-up in _initKebabMenus
keeps bottom rows' menus inside the visible box, and scrolling
dismisses an open menu before the scroller's edge could clip it. */
.admin-col-actions,
.admin-col-mactions {
overflow: visible;
@@ -951,6 +961,11 @@
* a hatch body, hatch.css restates this layout at higher specificity
* the .sh-body label.toggle-switch exception.) */
label.toggle-switch {
/* The containing block for the visually-hidden abspos input below: without
it the input sits at its static position outside whatever scroller the
toggle lives in (.sh-body, .admin-content) and focus-scrolls the wrong
ancestor inside a shelf that shoved the whole hatch off its dock. */
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
@@ -1090,6 +1105,11 @@ label.toggle-switch.toggle--flush {
first letters and unselected rows lose their dot entirely). */
.sh-body label.segmented-option,
.segmented-option {
/* The containing block for the visually-hidden abspos radio below same
anchor label.toggle-switch and .sh-body label.cap carry: an unanchored
input sits at its static position outside the .sh-body scroller and
focus-scrolls the wrong ancestor when the radiogroup is below the fold. */
position: relative;
display: flex;
align-items: center;
gap: 10px;
+55 -2
View File
@@ -86,6 +86,7 @@ from turnstone.core.memory import (
search_visible_structured_memories,
set_message_attachments,
set_workstream_alias,
touch_structured_memories,
update_workstream_title,
)
from turnstone.core.memory_relevance import (
@@ -1031,6 +1032,10 @@ class ChatSession:
# tool results) and the recent-context string is identical across
# them. Invalidated on user-turn append and on memory write/delete.
self._mem_search_cache: dict[tuple[str, str, int], list[dict[str, str]]] = {}
# Per-turn dedup for composition touches: ``_init_system_messages`` runs
# many times within a turn, so the injected set is touched at most once
# per memory per turn. Cleared alongside the search cache.
self._touched_memory_keys: set[tuple[str, str, str]] = set()
self._ws_id = ws_id or uuid.uuid4().hex
self._title_generated = False
self._read_files: set[str] = set()
@@ -2873,6 +2878,9 @@ class ChatSession:
candidates=len(visible_mems),
injected=len(relevant),
)
# Access metadata tracks what the model actually saw — touch the
# injected top-k, not the candidate pool.
self._touch_injected_memories(relevant)
if relevant:
dev_parts.append("")
dev_parts.append(build_memory_context(relevant))
@@ -7439,6 +7447,7 @@ class ChatSession:
def _invalidate_memory_cache(self) -> None:
"""Drop the per-turn search cache; call on user-turn append + memory writes."""
self._mem_search_cache.clear()
self._touched_memory_keys.clear()
def _select_memory_candidates(self, context: str) -> tuple[list[dict[str, str]], str]:
"""Pick the candidate set fed into BM25 ranking.
@@ -7476,6 +7485,38 @@ class ChatSession:
return extra, "recency"
return search_hits + extra, ("union" if extra else "search")
@staticmethod
def _memory_keys(rows: list[dict[str, str]]) -> list[tuple[str, str, str]]:
"""Build ``(name, scope, scope_id)`` touch keys from memory rows.
The storage read helpers return ``SELECT *`` rows, so all three
columns are present.
"""
return [(r.get("name", ""), r.get("scope", ""), r.get("scope_id", "")) for r in rows]
def _touch_injected_memories(self, rows: list[dict[str, str]]) -> None:
"""Touch the memories injected into the system prefix this turn.
``_init_system_messages`` recomposes many times per turn; gate on the
per-turn touched-key set so each surfaced memory is counted at most
once between user turns. Best-effort: the facade swallows storage
errors, so a failed touch never breaks composition.
"""
fresh = [k for k in self._memory_keys(rows) if k not in self._touched_memory_keys]
if not fresh:
return
self._touched_memory_keys.update(fresh)
touch_structured_memories(fresh)
def _touch_read_memories(self, rows: list[dict[str, str]]) -> None:
"""Touch memories returned by an explicit memory-tool read.
A search/get is a distinct user-driven access each time it runs, so
these are counted unconditionally (not subject to the composition
per-turn dedup). Best-effort via the facade.
"""
touch_structured_memories(self._memory_keys(rows))
def _check_metacognitive_nudge(self, user_message: str) -> tuple[str, str] | None:
"""Check if a metacognitive nudge should fire for *user_message*.
@@ -10012,7 +10053,17 @@ class ChatSession:
# Surface client-side validation errors as tool errors rather
# than rendering them as a "successful" wait result.
if result.get("error"):
msg = f"Error: {result['error']}"
if result.get("not_found") or result.get("invalid_ws_ids"):
# Unresolvable-id failures carry a structured recovery
# payload — per-id ``did_you_mean``, the children
# roster, and (on the in-loop abort) live ``results``
# for the still-observable lanes. Serialize the whole
# object so the model can fix the id and re-issue; a
# bare-string collapse would discard exactly the hints
# the client built for it.
msg = "Error: " + json.dumps(result, separators=(",", ":"), default=str)
else:
msg = f"Error: {result['error']}"
self._report_tool_result(call_id, "wait_for_workstream", msg, is_error=True)
self._emit_wait_event(
"wait_ended",
@@ -10023,7 +10074,7 @@ class ChatSession:
elapsed = result.get("elapsed", 0.0)
complete = result.get("complete", False)
# Count children that genuinely finished work (real terminals
# only — ``denied`` is a rejection, not a resolution). Earlier
# only — ``not_found`` is a rejection, not a resolution). Earlier
# versions counted any non-empty ``state`` and inverted the
# truth on timeout (rendered as ``"timeout (N/N resolved)"``).
# Inline import — ``turnstone.core`` shouldn't import from
@@ -11479,6 +11530,7 @@ class ChatSession:
found_scope = scope
break
if mem:
self._touch_read_memories([mem])
content = mem.get("content", "")
desc = mem.get("description", "")
mem_type = mem.get("type", "")
@@ -11556,6 +11608,7 @@ class ChatSession:
result_count=len(rows),
query=item["query"][:120],
)
self._touch_read_memories(rows)
if rows:
lines = []
for m in rows:
+1 -1
View File
@@ -4091,7 +4091,7 @@ def main() -> None:
parser.add_argument(
"--skip-permissions",
action="store_true",
help="Auto-approve all tool calls (no confirmation prompts)",
help="Auto-approve all tool calls without prompting (same as tools.skip_permissions)",
)
# MCP config path is bootstrap-critical (needed before ConfigStore for tool loading)
parser.add_argument(
+14 -2
View File
@@ -35,10 +35,18 @@
narrow-pane sheet breakpoint (a SPLIT pane is narrow even on a desktop
viewport, so the breakpoint is a container query, not a media query).
contain:layout (implied by container-type) also makes this the containing
block for the absolutely-positioned shelf. */
block for the absolutely-positioned shelf.
overflow must be `clip`, never `hidden`: hidden makes the host a
PROGRAMMATIC scroll container focus-into-view (tabbing to a control,
clicking a visually-hidden toggle input) silently scrolls it, dragging
the docked shelf and the pane content out of alignment with no scrollbar
to recover by. clip only clips paint; the host can never scroll, so
scrolling stays where it belongs the pane's interior scroller and the
shelf's .sh-body. */
.hatch-host {
position: relative;
overflow: hidden;
overflow: clip;
container-type: inline-size;
container-name: pane;
}
@@ -862,6 +870,10 @@
margin-top: 8px;
}
.sh-body label.cap {
/* The containing block for the visually-hidden abspos input below: without
it the input sits at its static position OUTSIDE the .sh-body scroller,
overhangs the shelf, and focus-scrolls the wrong ancestor. */
position: relative;
display: flex;
align-items: center;
gap: 8px;
+159
View File
@@ -630,6 +630,28 @@ class Pane {
}
});
// Click-to-play for media embeds. Pane-owned (on this.el) and root-scoped
// via closest(".media-play-btn") so every embedded L-shell pane activates
// its own players — the old standalone wired this via a document-level
// delegated listener in app.js, which the console host never loaded (so the
// Play button was dead in console-hosted panes). Enter on a focused button
// routes through the same path, mirroring the approval keydown above.
this.el.addEventListener("click", (e) => {
const btn = e.target.closest(".media-play-btn");
if (!btn) return;
e.preventDefault();
activateMediaPlayButton(btn);
});
this.el.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
const btn = e.target.closest(".media-play-btn");
if (!btn || btn.disabled) return;
// Single-path activation: preventDefault stops the browser's native
// Enter-to-click from dispatching a second activation behind ours.
e.preventDefault();
activateMediaPlayButton(btn);
});
// No pane header: the workstream name, persona, and state are shown by the
// tab and the rail (Workspaces); the --skip-permissions banner lands in
// messagesEl (see the host warningTarget). The standalone split-pane
@@ -2693,6 +2715,143 @@ function _tryPrettyJson(text) {
return _redactApiKeys(JSON.stringify(obj, null, 2));
}
// ---------------------------------------------------------------------------
// HLS lazy-loader + click-to-play (lifted from the standalone app.js so
// console-hosted panes activate media too). Follows the mermaid.js
// lazy-load pattern in /shared/renderer.js: the vendor is fetched by absolute
// /shared/ URL on first use, so it resolves in BOTH the standalone server and
// the console (where /shared is mounted at the root and node-proxied panes
// also reach it via /node/{id}/shared/).
// ---------------------------------------------------------------------------
let _hlsState = "idle";
let _hlsQueue = [];
function _loadHls(callback) {
if (_hlsState === "ready") {
callback();
return;
}
_hlsQueue.push(callback);
if (_hlsState === "loading") return;
_hlsState = "loading";
const script = document.createElement("script");
script.src = "/shared/hls-1.6.16/hls.min.js";
script.onload = function () {
_hlsState = "ready";
const q = _hlsQueue;
_hlsQueue = [];
for (let i = 0; i < q.length; i++) q[i]();
};
script.onerror = function () {
_hlsState = "idle";
const q = _hlsQueue;
_hlsQueue = [];
// Fall through — _activatePlayer will use stream_url since Hls is undefined
for (let i = 0; i < q.length; i++) q[i]();
};
document.head.appendChild(script);
}
function _isHlsUrl(url) {
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
}
function _activatePlayer(btn) {
const url = btn.dataset.streamUrl;
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
const directStream = btn.dataset.directStream === "true";
const player = document.createElement(isAudio ? "audio" : "video");
player.controls = true;
player.autoplay = true;
player.className = "media-player";
// Held so the error handler can tear the instance down before the player
// node is replaced — otherwise its listeners/loader timers run detached.
let hls = null;
// Prefer direct stream when the source supports it; fall back to HLS
// only when transcoding is needed.
if (directStream && url) {
player.src = url;
} else if (
hlsUrl &&
!isAudio &&
typeof Hls !== "undefined" &&
Hls.isSupported()
) {
hls = new Hls();
hls.loadSource(hlsUrl);
hls.attachMedia(player);
} else if (
hlsUrl &&
!isAudio &&
player.canPlayType("application/vnd.apple.mpegurl")
) {
player.src = hlsUrl;
} else {
player.src = url;
}
player.addEventListener("error", function () {
if (hls) {
hls.destroy();
hls = null; // media error events can repeat — never double-destroy
}
const card = player.closest(".media-embed");
const titleEl = card ? card.querySelector(".media-card-title") : null;
const label = titleEl ? ": " + titleEl.textContent : "";
const err = document.createElement("div");
err.className = "media-player-error";
err.setAttribute("role", "alert");
err.textContent = "Failed to load stream" + label;
const retry = document.createElement("button");
retry.className = "media-play-btn";
retry.type = "button";
retry.dataset.streamUrl = url;
retry.dataset.hlsUrl = hlsUrl || "";
retry.dataset.audioOnly = String(isAudio);
retry.dataset.directStream = String(directStream);
retry.setAttribute("aria-label", "Retry" + label);
retry.appendChild(document.createTextNode("▶ Retry"));
const container = document.createElement("div");
container.appendChild(err);
container.appendChild(retry);
player.replaceWith(container);
});
btn.replaceWith(player);
}
// Activate a clicked/Enter-pressed play button: show the loading affordance,
// then ensure hls.js is loaded before swapping in the player when the source
// needs it. The pane wires this from a root-scoped this.el listener.
function activateMediaPlayButton(btn) {
btn.disabled = true;
const labelEl = btn.querySelector("span:last-child");
if (labelEl) {
labelEl.textContent = "Loading…";
} else {
btn.textContent = "▶ Loading…";
}
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
// If HLS URL present and not audio, ensure hls.js is loaded first
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
_loadHls(function () {
_activatePlayer(btn);
});
} else {
_activatePlayer(btn);
}
}
function buildMediaCard(item) {
const card = document.createElement("div");
card.className = "media-card";
+55 -8
View File
@@ -723,6 +723,10 @@
.tab-menu {
animation: none;
}
.persona-btn,
.persona-led {
transition: none;
}
}
/* pane host ONE pane visible per tab (no split; the mock's 2-up was a
@@ -829,33 +833,76 @@
}
/* ===== Dashboard session launcher persona toggle (coordinator | interactive).
A console-dashboard control; lives here because the L-shell loads shell.css. */
A console-dashboard control; lives here because the L-shell loads shell.css.
The active option wears its KIND, not a neutral highlight: amber for
coordinator, cyan for interactive the same vocabulary as the pane-head
.ptag chips and the rail's session rows, so "which kind am I starting"
reads at a glance. Tints are 15% (sub-0.10 washes out at chip size) and
colour is never alone: the kind LED + label weight carry the state too. */
.launcher-personas {
display: inline-flex;
gap: 2px;
margin-bottom: 10px;
padding: 2px;
border: 1px solid var(--hair);
padding: 3px;
border: 1px solid var(--hair-2);
border-radius: var(--r-sm);
background: var(--panel-2);
background: var(--bg); /* recessed track — the .seg precedent */
}
.persona-btn {
padding: 4px 12px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 14px;
border: 0;
background: none;
color: var(--ink-3);
font-size: 12px;
font-weight: 500;
font-family: var(--font-ui);
/* r-sm, matching the shared :focus-visible rule below a literal here
would make the corner radius pop on keyboard focus */
border-radius: var(--r-sm);
cursor: pointer;
transition:
background 0.12s,
color 0.12s;
}
.persona-btn:hover {
color: var(--ink);
}
.persona-led {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink-4);
opacity: 0.35;
flex-shrink: 0;
transition:
background 0.12s,
opacity 0.12s,
box-shadow 0.12s;
}
.persona-btn.active {
background: var(--panel);
color: var(--ink);
box-shadow: inset 0 0 0 1px var(--hair-2);
font-weight: 600;
}
.persona-btn--coord.active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent);
}
.persona-btn--coord.active .persona-led {
background: var(--accent);
opacity: 1;
box-shadow: 0 0 6px var(--accent-glow-strong);
}
.persona-btn--int.active {
background: color-mix(in srgb, var(--cyan) 15%, transparent);
color: var(--cyan);
}
.persona-btn--int.active .persona-led {
background: var(--cyan);
opacity: 1;
box-shadow: 0 0 6px var(--cyan-glow);
}
/* persona tag in the saved-sessions table shares the rail chip base
(.row .tag above); only no-wrap + the INT colour differ. */
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id to cancel."
"description": "Workstream id to cancel — exactly 32 hex chars, copied verbatim. Unknown or malformed ids error with did-you-mean suggestions; nothing is ever guessed."
}
},
"required": ["ws_id"]
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id to soft-close."
"description": "Workstream id to soft-close — exactly 32 hex chars, copied verbatim. Unknown or malformed ids error with did-you-mean suggestions; nothing is ever guessed."
},
"reason": {
"type": "string",
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id to hard-delete."
"description": "Workstream id to hard-delete — exactly 32 hex chars, copied verbatim. Unknown or malformed ids error with did-you-mean suggestions; nothing is ever guessed (this verb is irreversible)."
}
},
"required": ["ws_id"]
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id to inspect."
"description": "Workstream id to inspect — exactly 32 hex chars, copied verbatim from spawn_batch / list_workstreams results. Unknown or malformed ids error with did-you-mean suggestions and a roster of your children; nothing is ever guessed."
},
"message_limit": {
"type": "integer",
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id that should receive the message."
"description": "Workstream id that should receive the message — exactly 32 hex chars, copied verbatim from spawn_batch / list_workstreams results (display names are not addresses)."
},
"message": {
"type": "string",
+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "wait_for_workstream",
"description": "Block until one or all named child workstreams reach a terminal state. Prefer this over busy-polling inspect_workstream after a fan-out: the tool absorbs the wait, so you get one call + one result regardless of duration. Returns `{results: {ws_id: {state, tokens, updated, message, truncated}}, elapsed, complete, mode}` — `results` is keyed by ws_id (not top-level), `complete` is true when the wait condition fired before the timeout. `message` is the child's last assistant text for `idle`/`error`, a short sentinel for `closed`/`denied`, and `null` for non-terminal rows so a follow-up read knows which children still need work; capped at 10 KiB UTF-8, with `truncated=true` when the cap fires (call inspect_workstream for the rest). Real terminal states: `idle`, `error`, `closed`, `deleted` (the last is unobservable since hard-delete cascades the row out of storage). `mode='any'` returns when the first child hits a real terminal — a `denied` id alone never satisfies the condition, so a typo'd / foreign / nonexistent id can't false-positive a wait. `mode='all'` returns once every id has settled (real terminal OR denied). Cross-tenant guard: only the coordinator's own children (or itself) are visible; everything else is reported `state='denied'`. Capped at 32 ws_ids and 600s; both overflows error rather than silently truncating.",
"description": "Block until one or all named child workstreams reach a terminal state. Prefer this over busy-polling inspect_workstream after a fan-out: the tool absorbs the wait, so you get one call + one result regardless of duration. Returns `{results: {ws_id: {state, tokens, updated, name, message, truncated}}, elapsed, complete, mode}` — `results` is keyed by ws_id (not top-level), `complete` is true when the wait condition fired before the timeout. `message` is the child's last assistant text for `idle`/`error`, a short sentinel for `closed`/`not_found`, and `null` for non-terminal rows so a follow-up read knows which children still need work; capped at 10 KiB UTF-8, with `truncated=true` when the cap fires (call inspect_workstream for the rest). Real terminal states: `idle`, `error`, `closed`, `deleted` (the last is unobservable since hard-delete cascades the row out of storage). `mode='any'` returns when the first child hits a real terminal; `mode='all'` returns once every id is real-terminal. Ids are validated up front — ws_ids are exactly 32 hex chars, copy them VERBATIM from spawn_batch/list_workstreams results; a malformed id (truncated/garbled/non-hex) errors immediately with `did_you_mean` suggestions and a roster of your children, and an id that can't be observed (foreign / nonexistent / hard-deleted mid-wait) aborts the wait on the tick that sees it with `state='not_found'` plus top-level `error`/`not_found`/`children` fields. When that happens, fix the id and re-issue — do NOT assume the child is dead; check `did_you_mean` or re-list. Child display names are labels, not addresses; always target ids. Capped at 32 ws_ids and 600s; both overflows error rather than silently truncating.",
"parameters": {
"type": "object",
"properties": {
"ws_ids": {
"type": "array",
"items": { "type": "string" },
"description": "Workstream ids to wait on. Accepts 1 or more; capped at 32 per call."
"description": "Workstream ids to wait on (each exactly 32 hex chars, copied verbatim from spawn_batch / list_workstreams results). Accepts 1 or more; capped at 32 per call. Malformed ids fail the call before any waiting; well-formed ids that can't be observed abort it on the first tick — both return did-you-mean suggestions."
},
"timeout": {
"type": "number",
@@ -17,7 +17,7 @@
"type": "string",
"enum": ["any", "all"],
"default": "any",
"description": "'any' (default) returns when the first child reaches a real terminal state (idle / error / closed / deleted); 'all' returns once every named child has settled (real terminal OR denied). A pure-denied list with mode='any' short-circuits to complete=false rather than spinning the timeout."
"description": "'any' (default) returns when the first child reaches a real terminal state (idle / error / closed / deleted); 'all' returns once every named child is real-terminal. An id that can't be observed (foreign / nonexistent / deleted mid-wait) aborts the wait immediately with state='not_found' and a top-level error, regardless of mode."
},
"since": {
"type": "object",
-135
View File
@@ -1569,141 +1569,6 @@ function _refreshConsentBadge() {
* Render the action card for an MCP error envelope. Mirrors the
* media-embed pattern: visible card on top, collapsible raw JSON below.
*/
// ---------------------------------------------------------------------------
// HLS lazy-loader (follows the mermaid.js lazy-load pattern in
// /shared/renderer.js)
// ---------------------------------------------------------------------------
let _hlsState = "idle";
let _hlsQueue = [];
function _loadHls(callback) {
if (_hlsState === "ready") {
callback();
return;
}
_hlsQueue.push(callback);
if (_hlsState === "loading") return;
_hlsState = "loading";
const script = document.createElement("script");
script.src = "/shared/hls-1.6.16/hls.min.js";
script.onload = function () {
_hlsState = "ready";
const q = _hlsQueue;
_hlsQueue = [];
for (let i = 0; i < q.length; i++) q[i]();
};
script.onerror = function () {
_hlsState = "idle";
const q = _hlsQueue;
_hlsQueue = [];
// Fall through — _activatePlayer will use stream_url since Hls is undefined
for (let i = 0; i < q.length; i++) q[i]();
};
document.head.appendChild(script);
}
function _isHlsUrl(url) {
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
}
// ---------------------------------------------------------------------------
// Click-to-play delegated handler (follows img-placeholder pattern)
// ---------------------------------------------------------------------------
function _activatePlayer(btn) {
const url = btn.dataset.streamUrl;
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
const directStream = btn.dataset.directStream === "true";
const player = document.createElement(isAudio ? "audio" : "video");
player.controls = true;
player.autoplay = true;
player.className = "media-player";
// Prefer direct stream when the source supports it; fall back to HLS
// only when transcoding is needed.
if (directStream && url) {
player.src = url;
} else if (
hlsUrl &&
!isAudio &&
typeof Hls !== "undefined" &&
Hls.isSupported()
) {
const hls = new Hls();
hls.loadSource(hlsUrl);
hls.attachMedia(player);
} else if (
hlsUrl &&
!isAudio &&
player.canPlayType("application/vnd.apple.mpegurl")
) {
player.src = hlsUrl;
} else {
player.src = url;
}
player.addEventListener("error", function () {
const card = player.closest(".media-embed");
const titleEl = card ? card.querySelector(".media-card-title") : null;
const label = titleEl ? ": " + titleEl.textContent : "";
const err = document.createElement("div");
err.className = "media-player-error";
err.setAttribute("role", "alert");
err.textContent = "Failed to load stream" + label;
const retry = document.createElement("button");
retry.className = "media-play-btn";
retry.type = "button";
retry.dataset.streamUrl = url;
retry.dataset.hlsUrl = hlsUrl || "";
retry.dataset.audioOnly = String(isAudio);
retry.dataset.directStream = String(directStream);
retry.setAttribute("aria-label", "Retry" + label);
retry.appendChild(document.createTextNode("\u25b6 Retry"));
const container = document.createElement("div");
container.appendChild(err);
container.appendChild(retry);
player.replaceWith(container);
});
btn.replaceWith(player);
}
document.addEventListener("click", function (e) {
const btn = e.target.closest(".media-play-btn");
if (!btn) return;
e.preventDefault();
btn.disabled = true;
const labelEl = btn.querySelector("span:last-child");
if (labelEl) {
labelEl.textContent = "Loading\u2026";
} else {
btn.textContent = "\u25b6 Loading\u2026";
}
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
// If HLS URL present and not audio, ensure hls.js is loaded first
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
_loadHls(function () {
_activatePlayer(btn);
});
} else {
_activatePlayer(btn);
}
});
document.addEventListener("keydown", function (e) {
if (e.key !== "Enter") return;
const btn = e.target.closest(".media-play-btn");
if (!btn) return;
btn.click();
});
function _announce(text) {
const el = document.getElementById("toast");
if (!el) return;
Generated
+1 -1
View File
@@ -2324,7 +2324,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.6.0rc2"
version = "1.6.1"
source = { editable = "." }
dependencies = [
{ name = "alembic" },